Skip to content

Instantly share code, notes, and snippets.

@lukepighetti
Last active June 28, 2021 11:04
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lukepighetti/4dca62a2f55c266ceb2fdeb3afcde044 to your computer and use it in GitHub Desktop.
Save lukepighetti/4dca62a2f55c266ceb2fdeb3afcde044 to your computer and use it in GitHub Desktop.

This is a pattern I came up with when I was working on a fairly large one person app (25k LOC, legal/regulation calculator, lots of unit testing). It is very simple and very effective.

The short version is you have a RootStore that listens to all of it's child stores. Each store has an async initState method and a dispose method. Make observer mixins for platform channel related items like App Lifecycle, Geolocation, Geofencing that add more lifecycle methods to these stores and manage their own initState/dispose.

I realize that a gist is going to look like a bit of a cluster for something of this nature but I bet if you pull it all down into a project and start playing around with it you'll really enjoy it. Especially now that provider has context.select.

Here's an example of my folder structure in lib/features as a bit of a bonus

lib/features
├── animations
│   └── animations_store.dart
├── announcements
│   ├── announcements_store.dart
│   ├── models
│   │   └── announcement.dart
│   └── widgets
│       ├── current_announcements.dart
│       └── section_announcement_notice.dart
├── atmosphere
│   ├── atmosphere_enums.dart
│   ├── atmosphere_slug.dart
│   ├── atmosphere_store.dart
│   ├── season_dates
│   │   ├── annual_season_dates.dart
│   │   ├── astronomical_season_dates.dart
│   │   └── wmd_season_dates.dart
│   └── widgets
│       ├── atmosphere_background.dart
│       └── atmosphere_debug_callout.dart
├── debug_controls
│   └── debug_controls.dart
├── geofencing
│   ├── geofencing_delorme_tiles.dart
│   ├── geofencing_extensions.dart
│   ├── geofencing_search_extensions.dart
│   ├── geofencing_store.dart
│   ├── geofencing_twp_extensions.dart
│   ├── geofencing_wmd_extensions.dart
│   └── widgets
│       ├── current_delorme_tile_callout.dart
│       ├── current_township_callout.dart
│       └── current_wmd_callout.dart
├── geolocation
│   ├── geolocation_constants.dart
│   ├── geolocation_service.dart
│   ├── geolocation_store.dart
│   └── widgets
│       └── geolocation_debug_controls.dart
├── hours
│   ├── hours_store.dart
│   ├── models
│   │   ├── hunting_day_model.dart
│   │   └── hunting_state_change_model.dart
│   ├── time_table_constants.dart
│   └── widgets
│       ├── hours_debug_controls.dart
│       ├── hours_progress_bar.dart
│       └── hunting_hours_chip.dart
├── onboarding
│   ├── onboarding_store.dart
│   ├── screens
│   │   └── onboarding_screen.dart
│   └── widgets
│       └── moose_season_cards_example.dart
├── permissions
│   ├── permissions_service.dart
│   ├── permissions_store.dart
│   └── widgets
│       ├── location_services_button.dart
│       ├── location_services_callout_section.dart
│       └── permissions_debug_controls.dart
├── persistance
│   ├── persistance_store.dart
│   └── widgets
│       ├── clear_app_data_button.dart
│       └── persistance_debug_controls.dart
├── reporting
│   └── reporting_store.dart
├── search
│   ├── search_extensions.dart
│   ├── search_none_found.dart
│   └── search_store.dart
├── seasons
│   ├── models
│   │   ├── day_of_year.dart
│   │   ├── day_of_year_range.dart
│   │   ├── season_model.dart
│   │   ├── species_seasons_result.dart
│   │   └── time_of_day_range.dart
│   ├── screens
│   │   └── seasons_available_screen.dart
│   ├── seasons_constants.dart
│   ├── seasons_filter_extensions.dart
│   ├── seasons_store.dart
│   └── widgets
│       ├── open_species_chip_cloud.dart
│       ├── season_cards.dart
│       ├── season_group_section.dart
│       └── upcoming_seasons.dart
├── species
│   ├── species_constants.dart
│   └── species_model.dart
├── store_observers
│   ├── app_lifecycle_observer.dart
│   ├── geolocation_change_observer.dart
│   └── location_permission_observer.dart
├── stores
│   ├── child_store.dart
│   ├── interval_rebuild.dart
│   ├── root_store.dart
│   ├── store.dart
│   ├── store_change_extension.dart
│   ├── store_change_observer.dart
│   ├── stores.dart
│   ├── stubbable_now.dart
│   └── stubbable_position.dart
├── svg_precache
│   └── svg_precache_store.dart
├── take
│   ├── take_constants.dart
│   └── take_model.dart
└── wmd
    ├── models
    │   └── wmd_model.dart
    ├── screens
    │   └── wmd_selection_screen.dart
    ├── widgets
    │   └── wmd_radio_list_tile.dart
    ├── wmd_constants.dart
    └── wmd_store.dart
import 'package:flutter/foundation.dart';
/// A [ChangeNotifier] with built in fields for [initState], [initialized] and [setState].
///
/// Contains multiple [ChildStore]s
abstract class Store extends ChangeNotifier {
/// Initialize this store.
///
/// Must call super after initialization is complete.
@mustCallSuper
Future<void> initState() async {
_initialized = true;
notifyListeners();
}
/// If this [ChildStore] is fully initialized
bool get initialized => _initialized ?? false;
bool _initialized = false;
/// Alternative to notifyListeners. Matches [StatefulWidget] semantics.
void setState(Function fn) {
fn();
notifyListeners();
}
}
import 'store.dart';
/// A [Store] that has a [registerStores]
/// method which connects these child stores to this [RootStore],
/// initializes them and then disposes of the subscription.
abstract class RootStore extends Store {
List<Store> _children = [];
/// Register stores with this [RootStore].
///
/// The root store will subscribe to all childrens
/// [notifyListeners] calls, initialize them, and dispose
/// of the subscription.
void registerChildren(List<Store> children) {
_children = children;
}
@override
Future<void> initState() async {
if (_children.isEmpty)
print(
"WARNING: $this has no _children. Typically a RootStore would have _children stores.",
);
/// Connect the root store's [notifyListeners] method to
/// all child stores [notifyListeners] method.
_children.forEach((e) => e.addListener(notifyListeners));
/// Await all child store's [initState]
await Future.wait(_children.map((e) => e.initState()));
return super.initState();
}
@override
void dispose() {
/// Dispose all [notifyListeners] handlers.
_children.forEach((e) {
e.removeListener(notifyListeners);
e.dispose();
});
super.dispose();
}
}
import 'package:flutter/foundation.dart';
import 'store.dart';
/// A [ChangeNotifier] with built in fields for [RootStore], [initState],
/// [initialized] and [setState].
abstract class ChildStore<T extends Store> extends Store {
ChildStore(this.store);
/// The parent store that contains this store.
final T store;
}
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'store.dart';
extension StoreChangeExtension<S extends Store> on S {
Future<T> changeFuture<T>(T Function(S) onChange) async {
/// Setup a completer
final completer = Completer<T>();
/// Get the current value for onChange.
T current = onChange(this);
/// Setup a listener
void listener() async {
final next = onChange(this);
/// Check if there's a change
if (next != current) {
completer.complete(next);
this.removeListener(listener);
}
}
this.addListener(listener);
return completer.future;
}
}
class StoreChangeObserver<T, K extends Store> {
/// Observe a store and if [observe] changes, fire [onChange]
StoreChangeObserver({
@required this.store,
@required this.observe,
@required this.onChange,
});
/// The store to listen to for changes.
final K store;
/// The field on the store to check for changes.
final T Function(K) observe;
/// The callback to fire when [observe] changes.
final void Function(T) onChange;
Future<void> initState() async {
/// Setup the change listener
store.addListener(_listener);
/// Trigger the first value
_listener();
}
void dispose() {
/// Remove the change listener
store.removeListener(_listener);
}
/// Store the previous value so we can determine when a change has occurred.
T _previous;
bool _hasDispatchedFirstValue = false;
void _listener() {
/// Get the current value.
final next = observe(store);
final hasChanged = _previous != next;
/// This is the first value, let's dispatch it and ensure we don't trigger this multiple times.
if (_hasDispatchedFirstValue == false) {
_hasDispatchedFirstValue = true;
_previous = next;
onChange(next);
}
/// There's been an update, let's save the last value so we don't trigger this multiple times.
else if (hasChanged) {
_previous = next;
onChange(next);
}
}
}
import 'package:flutter/material.dart';
import 'package:mh/features/stores/stores.dart';
/// A mixin that observes when the app lifecycle changes.
///
/// An easier alternative to using [WidgetsBindingObserver]
mixin AppLifecycleObserver on Store {
_LifecycleBindingObserver _observer;
@override
Future<void> initState() async {
_observer = _LifecycleBindingObserver(didChangeAppLifecycleState);
WidgetsBinding.instance.addObserver(_observer);
return super.initState();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(_observer);
super.dispose();
}
/// See [WidgetsBindingObserver.didChangeAppLifecycleState]
@mustCallSuper
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
onAppLifecycleResumed();
break;
case AppLifecycleState.inactive:
onAppLifecycleInactive();
break;
case AppLifecycleState.paused:
onAppLifecyclePaused();
break;
case AppLifecycleState.detached:
onAppLifecycleDetached();
break;
}
}
/// See [AppLifecycleState.resumed]
void onAppLifecycleResumed() {}
/// See [AppLifecycleState.inactive]
void onAppLifecycleInactive() {}
/// See [AppLifecycleState.paused]
void onAppLifecyclePaused() {}
/// See [AppLifecycleState.detatched]
void onAppLifecycleDetached() {}
}
class _LifecycleBindingObserver with WidgetsBindingObserver {
/// A simple wrapper for [WidgetsBindingObserver.didChangeAppLifecycleState]
_LifecycleBindingObserver(this.onChangeAppLifecycleState);
final void Function(AppLifecycleState state) onChangeAppLifecycleState;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onChangeAppLifecycleState(state);
}
}
void main() async {
/// Ensure that platform channels can be accessed.
WidgetsFlutterBinding.ensureInitialized();
/// Set the orientations supported by the app
await SystemChrome.setPreferredOrientations(AppConstants.appOrientations);
/// Initialize the app's root store.
final appStore = AppStore();
await appStore.initState();
runApp(MultiProvider(
providers: [
/// App root store
ChangeNotifierProvider<AppStore>.value(
value: appStore,
),
],
child: MyApp(),
));
}
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import '../features/animations/animations_store.dart';
import '../features/announcements/announcements_store.dart';
import '../features/atmosphere/atmosphere_store.dart';
import '../features/geofencing/geofencing_store.dart';
import '../features/geolocation/geolocation_store.dart';
import '../features/hours/hours_store.dart';
import '../features/onboarding/onboarding_store.dart';
import '../features/permissions/permissions_store.dart';
import '../features/persistance/persistance_store.dart';
import '../features/reporting/reporting_store.dart';
import '../features/search/search_store.dart';
import '../features/seasons/seasons_store.dart';
import '../features/stores/interval_rebuild.dart';
import '../features/stores/stores.dart';
import '../features/svg_precache/svg_precache_store.dart';
import '../features/wmd/wmd_store.dart';
class AppStore extends RootStore with IntervalRebuild {
/// The [RootStore] for the app.
AppStore() {
animations = AnimationsStore(this);
announcements = AnnouncementsStore(this);
atmosphere = AtmosphereStore(this);
geofencing = GeofencingStore(this);
geolocation = GeolocationStore(this);
hours = HoursStore(this);
onboarding = OnboardingStore(this);
permissions = PermissionsStore(this);
persistance = PersistanceStore(this);
reporting = ReportingStore(this);
search = SearchStore(this);
seasons = SeasonsStore(this);
svgPrecache = SvgPrecacheStore(this);
wmd = WmdStore(this);
/// Make sure to register every [Store]!
registerChildren([
animations,
announcements,
atmosphere,
geofencing,
geolocation,
hours,
onboarding,
permissions,
persistance,
reporting,
search,
seasons,
svgPrecache,
wmd,
]);
}
AnimationsStore animations;
AnnouncementsStore announcements;
AtmosphereStore atmosphere;
GeofencingStore geofencing;
GeolocationStore geolocation;
HoursStore hours;
OnboardingStore onboarding;
PermissionsStore permissions;
PersistanceStore persistance;
ReportingStore reporting;
SearchStore search;
SeasonsStore seasons;
SvgPrecacheStore svgPrecache;
WmdStore wmd;
static AppStore of(BuildContext context, [bool listen = true]) =>
Provider.of<AppStore>(context, listen: listen);
}
import 'package:app/app/app_store.dart';
import 'package:app/features/animations/animations_store.dart';
import 'package:app/features/announcements/announcements_store.dart';
import 'package:app/features/atmosphere/atmosphere_store.dart';
import 'package:app/features/geofencing/geofencing_store.dart';
import 'package:app/features/geolocation/geolocation_store.dart';
import 'package:app/features/hours/hours_store.dart';
import 'package:app/features/onboarding/onboarding_store.dart';
import 'package:app/features/permissions/permissions_store.dart';
import 'package:app/features/persistance/persistance_store.dart';
import 'package:app/features/reporting/reporting_store.dart';
import 'package:app/features/search/search_store.dart';
import 'package:app/features/seasons/seasons_store.dart';
import 'package:app/features/stores/root_store.dart';
import 'package:app/features/stores/stores.dart';
import 'package:app/features/svg_precache/svg_precache_store.dart';
import 'package:app/features/wmd/wmd_store.dart';
import 'mock_animations_store.dart';
import 'mock_announcements_store.dart';
import 'mock_atmosphere_store.dart';
import 'mock_geofencing_store.dart';
import 'mock_geolocation_store.dart';
import 'mock_hours_store.dart';
import 'mock_onboarding_store.dart';
import 'mock_permissions_store.dart';
import 'mock_persistance_store.dart';
import 'mock_reporting_store.dart';
import 'mock_search_store.dart';
import 'mock_seasons_store.dart';
import 'mock_wmd_store.dart';
/// A mock root store for this app.
///
/// When swapping out child stores make sure you register them using this procedure:
///
/// ```dart
/// /// Create root and child stores
/// final store = AppStore();
/// final permissions = MockPermissionsStore(store);
///
/// /// Set the root store fields and register children
/// store.permissions = permissions;
/// store.registerChildren([store.permissions]);
///
/// /// Init root store and registered children
/// await store.initState();
/// ```
class MockAppStore extends RootStore implements AppStore {
MockAppStore() {
animations = MockAnimationsStore(this);
announcements = MockAnnouncementsStore(this);
atmosphere = MockAtmosphereStore(this);
geofencing = MockGeofencingStore(this);
geolocation = MockGeolocationStore(this);
hours = MockHoursStore(this);
onboarding = MockOnboardingStore(this);
permissions = MockPermissionsStore(this);
persistance = MockPersistanceStore(this);
reporting = MockReportingStore(this);
search = MockSearchStore(this);
seasons = MockSeasonsStore(this);
wmd = MockWmdStore(this);
registerChildren([
animations,
announcements,
geofencing,
geolocation,
hours,
onboarding,
permissions,
persistance,
reporting,
search,
seasons,
wmd,
]);
}
@override
AnimationsStore animations;
@override
AnnouncementsStore announcements;
@override
AtmosphereStore atmosphere;
@override
GeofencingStore geofencing;
@override
GeolocationStore geolocation;
@override
HoursStore hours;
@override
OnboardingStore onboarding;
@override
PermissionsStore permissions;
@override
PersistanceStore persistance;
@override
ReportingStore reporting;
@override
SearchStore search;
@override
SeasonsStore seasons;
@override
SvgPrecacheStore svgPrecache;
@override
WmdStore wmd;
}
import 'package:flutter/material.dart';
import 'package:mh/app/app_store.dart';
import 'package:mh/extensions/extensions.dart';
import 'package:mh/features/atmosphere/season_dates/annual_season_dates.dart';
import 'package:mh/features/atmosphere/season_dates/astronomical_season_dates.dart';
import 'package:mh/features/atmosphere/season_dates/wmd_season_dates.dart';
import 'package:mh/features/seasons/models/time_of_day_range.dart';
import 'package:mh/features/stores/stores.dart';
import 'atmosphere_enums.dart';
class AtmosphereStore extends ChildStore<AppStore> {
/// The current seasonal atmosphere. Used to determine how the app should
/// look and feel.
AtmosphereStore(AppStore store) : super(store);
DateTime get now => store.hours.now;
/// The app theme mode based on time of day. Specifically legal hunting hours.
ThemeMode get themeMode {
switch (partOfDay) {
case AtmospherePartOfDay.day:
return ThemeMode.light;
case AtmospherePartOfDay.night:
default:
return ThemeMode.dark;
}
}
/// The current part of day to drive the look and feel of the app.
AtmospherePartOfDay get partOfDay {
final hours = store.hours;
final time = now.asTimeOfDay;
TimeOfDayRange legalHours;
/// If it's Sunday, we need to grab legal hours from the previous day,
/// just so we can present day/night.
if (hours.isSunday)
legalHours = hours.yesterday.legalHours;
/// If it's a weekday, we can use the legal hours for today.
else
legalHours = hours.today.legalHours;
final isDayTime = legalHours.contains(time);
if (isDayTime)
return AtmospherePartOfDay.day;
else
return AtmospherePartOfDay.night;
}
/// The current season to drive the look and feel of the app.
AtmosphereSeason get season {
final today = now.asDayOfYear;
AnnualSeasonDates seasons;
/// Use wmd based seasons if we have a current WMD.
if (store.geofencing.hasCurrentWmd) {
final wmd = store.geofencing.currentWmd;
seasons = generateWmdSeasons(now.year, wmd);
}
/// Use astronomical seasons if we're outside of Maine.
else {
seasons = generateAstronomicalSeasons(now.year);
}
final springBegins = seasons.springStarts.asDayOfYear;
final summerBegins = seasons.summerStarts.asDayOfYear;
final autumnBegins = seasons.autumnStarts.asDayOfYear;
final winterBegins = seasons.winterStarts.asDayOfYear;
/// Winter
if (today.isAfter(winterBegins) || today.isBefore(springBegins))
return AtmosphereSeason.winter;
/// Autumn
else if (today.isAfter(autumnBegins))
return AtmosphereSeason.autumn;
/// Summer
else if (today.isAfter(summerBegins))
return AtmosphereSeason.summer;
/// Spring
else if (today.isAfter(springBegins))
return AtmosphereSeason.spring;
/// Error, show that it's Autumn because it's pretty.
else {
store.reporting.reportError(
source: 'AtmosphereStore.season',
message:
"Couldn't determine if it's spring, summer, autumn, or winter.",
payload: {
'now': now,
},
);
return AtmosphereSeason.autumn;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment