Repository: marcglasberg/async_redux Branch: master Commit: 8adc3bb8e7d7 Files: 232 Total size: 2.0 MB Directory structure: gitextract_bzwe_hyt/ ├── .claude/ │ ├── settings.local.json │ └── skills/ │ ├── asyncredux-abort-dispatch/ │ │ └── SKILL.md │ ├── asyncredux-action-status/ │ │ └── SKILL.md │ ├── asyncredux-actions-no-state-change/ │ │ └── SKILL.md │ ├── asyncredux-async-actions/ │ │ └── SKILL.md │ ├── asyncredux-base-action/ │ │ └── SKILL.md │ ├── asyncredux-before-after/ │ │ └── SKILL.md │ ├── asyncredux-check-internet-mixin/ │ │ └── SKILL.md │ ├── asyncredux-connector-pattern/ │ │ └── SKILL.md │ ├── asyncredux-debounce-mixin/ │ │ └── SKILL.md │ ├── asyncredux-debugging/ │ │ └── SKILL.md │ ├── asyncredux-dependency-injection/ │ │ └── SKILL.md │ ├── asyncredux-dispatching-actions/ │ │ └── SKILL.md │ ├── asyncredux-error-handling/ │ │ └── SKILL.md │ ├── asyncredux-events/ │ │ └── SKILL.md │ ├── asyncredux-flutter-hooks/ │ │ └── SKILL.md │ ├── asyncredux-navigation/ │ │ └── SKILL.md │ ├── asyncredux-nonreentrant-mixin/ │ │ └── SKILL.md │ ├── asyncredux-observers/ │ │ └── SKILL.md │ ├── asyncredux-optimistic-update-mixin/ │ │ └── SKILL.md │ ├── asyncredux-persistence/ │ │ └── SKILL.md │ ├── asyncredux-provider-integration/ │ │ └── SKILL.md │ ├── asyncredux-retry-mixin/ │ │ └── SKILL.md │ ├── asyncredux-selectors/ │ │ └── SKILL.md │ ├── asyncredux-setup/ │ │ └── SKILL.md │ ├── asyncredux-state-access/ │ │ └── SKILL.md │ ├── asyncredux-state-design/ │ │ └── SKILL.md │ ├── asyncredux-streams-timers/ │ │ └── SKILL.md │ ├── asyncredux-sync-actions/ │ │ └── SKILL.md │ ├── asyncredux-testing-basics/ │ │ └── SKILL.md │ ├── asyncredux-testing-view-models/ │ │ └── SKILL.md │ ├── asyncredux-testing-wait-methods/ │ │ └── SKILL.md │ ├── asyncredux-throttle-mixin/ │ │ └── SKILL.md │ ├── asyncredux-undo-redo/ │ │ └── SKILL.md │ ├── asyncredux-user-exceptions/ │ │ └── SKILL.md │ ├── asyncredux-wait-condition/ │ │ └── SKILL.md │ └── asyncredux-wait-fail-succeed/ │ └── SKILL.md ├── .github/ │ ├── copilot-instructions.md │ └── workflows/ │ └── test.yaml ├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── context_select_patterns.md ├── example/ │ ├── .gitignore │ ├── .metadata │ ├── README.md │ ├── analysis_options.yaml │ ├── android/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ ├── debug/ │ │ │ │ └── AndroidManifest.xml │ │ │ ├── main/ │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── example/ │ │ │ │ │ └── example/ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── res/ │ │ │ │ ├── drawable/ │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── drawable-v21/ │ │ │ │ │ └── launch_background.xml │ │ │ │ ├── values/ │ │ │ │ │ └── styles.xml │ │ │ │ └── values-night/ │ │ │ │ └── styles.xml │ │ │ └── profile/ │ │ │ └── AndroidManifest.xml │ │ ├── build.gradle.kts │ │ ├── gradle/ │ │ │ └── wrapper/ │ │ │ └── gradle-wrapper.properties │ │ ├── gradle.properties │ │ └── settings.gradle.kts │ ├── ios/ │ │ ├── .gitignore │ │ ├── Flutter/ │ │ │ ├── AppFrameworkInfo.plist │ │ │ ├── Debug.xcconfig │ │ │ └── Release.xcconfig │ │ ├── Runner/ │ │ │ ├── AppDelegate.swift │ │ │ ├── Assets.xcassets/ │ │ │ │ ├── AppIcon.appiconset/ │ │ │ │ │ └── Contents.json │ │ │ │ └── LaunchImage.imageset/ │ │ │ │ ├── Contents.json │ │ │ │ └── README.md │ │ │ ├── Base.lproj/ │ │ │ │ ├── LaunchScreen.storyboard │ │ │ │ └── Main.storyboard │ │ │ ├── Info.plist │ │ │ └── Runner-Bridging-Header.h │ │ ├── Runner.xcodeproj/ │ │ │ ├── project.pbxproj │ │ │ ├── project.xcworkspace/ │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcshareddata/ │ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ │ └── WorkspaceSettings.xcsettings │ │ │ └── xcshareddata/ │ │ │ └── xcschemes/ │ │ │ └── Runner.xcscheme │ │ ├── Runner.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── WorkspaceSettings.xcsettings │ │ └── RunnerTests/ │ │ └── RunnerTests.swift │ ├── lib/ │ │ ├── main.dart │ │ ├── main_before_and_after.dart │ │ ├── main_dependency_injection.dart │ │ ├── main_event.dart │ │ ├── main_event_2.dart │ │ ├── main_infinite_scroll.dart │ │ ├── main_is_waiting_works_when_multiple_actions.dart │ │ ├── main_navigate.dart │ │ ├── main_optimistic_command.dart │ │ ├── main_optimistic_sync.dart │ │ ├── main_optimistic_sync_with_push.dart │ │ ├── main_polling.dart │ │ ├── main_select.dart │ │ ├── main_select_2.dart │ │ ├── main_show_error_dialog.dart │ │ ├── main_show_spinner.dart │ │ ├── main_wait_action_advanced_1.dart │ │ ├── main_wait_action_advanced_2.dart │ │ ├── main_wait_action_simple.dart │ │ └── store_connector_examples/ │ │ ├── README.md │ │ ├── main_async__store_connector.dart │ │ ├── main_async_base_factory__store_connector.dart.dart │ │ ├── main_environment__store_connector.dart │ │ ├── main_event__store_connector.dart │ │ ├── main_extension_vs_store_connector.dart │ │ ├── main_infinite_scroll__store_connector.dart │ │ ├── main_is_waiting_works_when_multiple_actions__store_connector.dart │ │ ├── main_is_waiting_works_when_state_unchanged__store_connector.dart │ │ ├── main_navigate__store_connector.dart │ │ ├── main_null_viewmodel__connector.dart │ │ ├── main_should_update_model__store_connector.dart │ │ ├── main_spinner__store_connector.dart │ │ ├── main_static_view_model__store_connector.dart │ │ ├── main_sync__store_connector.dart │ │ ├── main_wait_action_advanced_1__store_connector.dart │ │ ├── main_wait_action_advanced_2__store_connector.dart │ │ └── main_wait_action_simple__store_connector.dart │ ├── pubspec.yaml │ ├── web/ │ │ ├── index.html │ │ └── manifest.json │ └── windows/ │ ├── .gitignore │ ├── CMakeLists.txt │ ├── flutter/ │ │ ├── CMakeLists.txt │ │ ├── generated_plugin_registrant.cc │ │ ├── generated_plugin_registrant.h │ │ └── generated_plugins.cmake │ └── runner/ │ ├── CMakeLists.txt │ ├── Runner.rc │ ├── flutter_window.cpp │ ├── flutter_window.h │ ├── main.cpp │ ├── resource.h │ ├── runner.exe.manifest │ ├── utils.cpp │ ├── utils.h │ ├── win32_window.cpp │ └── win32_window.h ├── lib/ │ ├── async_redux.dart │ ├── local_json_persist.dart │ ├── local_persist.dart │ └── src/ │ ├── action_mixins.dart │ ├── action_observer.dart │ ├── advanced_user_exception.dart │ ├── cache.dart │ ├── cloud_sync.dart │ ├── connection_exception.dart │ ├── connector_tester.dart │ ├── error_observer.dart │ ├── event_redux.dart │ ├── global_wrap_error.dart │ ├── local_json_persist.dart │ ├── local_persist.dart │ ├── log.dart │ ├── mock_build_context.dart │ ├── mock_store.dart │ ├── model_observer.dart │ ├── navigate_action.dart │ ├── persistor.dart │ ├── process_persistence.dart │ ├── redux_action.dart │ ├── show_dialog_super.dart │ ├── state_observer.dart │ ├── store.dart │ ├── store_exception.dart │ ├── store_provider_and_connector.dart │ ├── store_tester.dart │ ├── test_info.dart │ ├── user_exception_dialog.dart │ ├── view_model.dart │ ├── wait.dart │ ├── wait_action.dart │ └── wrap_reduce.dart ├── mixin_compatibility.md ├── pubspec.yaml └── test/ ├── abort_dispatch_test.dart ├── action_initial_state_test.dart ├── action_status_test.dart ├── action_to_string_test.dart ├── action_wrap_reduce2_test.dart ├── action_wrap_reduce_test.dart ├── after_throws_test.dart ├── before_reduce_after_order_test.dart ├── before_throwing_errors_test.dart ├── cache_test.dart ├── check_internet_mixin_test.dart ├── context_environment_test.dart ├── context_event_test.dart ├── context_select_advanced_test.dart ├── context_select_test.dart ├── context_state_test.dart ├── debounce_mixin_test.dart ├── dispatch_and_wait_all_actions_test.dart ├── dispatch_and_wait_test.dart ├── dispatch_sync_test.dart ├── dispatch_test.dart ├── event_redux_test.dart ├── failed_action_test.dart ├── fresh_mixin_test.dart ├── local_json_persist_test.dart ├── local_persist_test.dart ├── mock_build_context_test.dart ├── mock_store_test.dart ├── model_observer_test.dart ├── navigate_action_test.dart ├── non_reentrant_test.dart ├── optimistic_command_mixin_test.dart ├── optimistic_sync_test.dart ├── optimistic_sync_with_push_test.dart ├── persistence_test.dart ├── polling_mixin_test.dart ├── props_test.dart ├── reducer_future_or_test.dart ├── retry_mixin_test.dart ├── server_push_init_test.dart ├── server_push_test.dart ├── store_connector_test.dart ├── store_observer_test.dart ├── store_provider_test.dart ├── store_tester_test.dart ├── store_wait_action_test.dart ├── store_wrap_reduce_test.dart ├── sync_async_test.dart ├── test_utils.dart ├── throttle_mixin_test.dart ├── user_exception_test.dart ├── view_model_test.dart └── wait_action_test.dart ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.local.json ================================================ { "permissions": { "allow": [ "Bash(flutter test:*)", "Bash(flutter analyze:*)", "Bash(dart test:*)", "WebFetch(domain:asyncredux.com)", "WebFetch(domain:pub.dev)", "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:github.com)", "Bash(curl:*)", "Bash(ls:*)", "mcp__dart__pub_dev_search", "mcp__dart__pub", "mcp__dart__analyze_files", "mcp__dart__run_tests" ], "deny": [], "ask": [] } } ================================================ FILE: .claude/skills/asyncredux-abort-dispatch/SKILL.md ================================================ --- name: asyncredux-abort-dispatch description: Stops an AsyncRedux (Flutter) action from dispatching. Use only when the user mentions abortDispatch(), or explicitly asks to abort or prevent dispatch under certain conditions. --- # AsyncRedux Aborting the Dispatch ## What is abortDispatch()? The `abortDispatch()` method is an optional method on `ReduxAction` that lets you conditionally prevent an action from executing. When this method returns `true`, the entire action is skipped—`before()`, `reduce()`, and `after()` will NOT run, and state remains unchanged. ```dart class MyAction extends ReduxAction { @override bool abortDispatch() { // Return true to abort, false to proceed return someCondition; } @override AppState? reduce() { // Only runs if abortDispatch() returned false return state.copy(/* ... */); } } ``` ## Basic Usage The simplest use case is checking a condition before allowing the action to proceed: ```dart class LoadUserProfile extends ReduxAction { @override bool abortDispatch() => state.user == null; @override Future reduce() async { // Only runs if user is logged in final profile = await api.fetchProfile(state.user!.id); return state.copy(profile: profile); } } ``` ## Action Lifecycle with abortDispatch() When `abortDispatch()` returns `true`, the complete action lifecycle is skipped: ```dart class MyAction extends ReduxAction { @override bool abortDispatch() => state.shouldSkip; // If true: @override void before() { // NOT called when aborted } @override AppState? reduce() { // NOT called when aborted } @override void after() { // NOT called when aborted } } ``` This differs from throwing an error in `before()`, which would still cause `after()` to run. ## Authentication Guard Pattern A common pattern is creating a base action that requires authentication: ```dart /// Base action that requires an authenticated user abstract class AuthenticatedAction extends ReduxAction { @override bool abortDispatch() => state.user == null; } /// Actions extending this will only run when user is logged in class FetchUserOrders extends AuthenticatedAction { @override Future reduce() async { // Safe to use state.user! here - abortDispatch ensures it's not null final orders = await api.getOrders(state.user!.id); return state.copy(orders: orders); } } class UpdateUserSettings extends AuthenticatedAction { final Settings newSettings; UpdateUserSettings(this.newSettings); @override Future reduce() async { await api.updateSettings(state.user!.id, newSettings); return state.copy(settings: newSettings); } } ``` ## Creating Base Actions with Abort Logic You can combine multiple abort conditions in a base action: ```dart abstract class AppAction extends ReduxAction { // Override in subclasses to add action-specific abort logic bool shouldAbort() => false; @override bool abortDispatch() { // Global abort conditions if (state.isMaintenanceMode) return true; if (state.isAppLocked) return true; // Action-specific abort conditions return shouldAbort(); } } class RefreshData extends AppAction { @override bool shouldAbort() { // Don't refresh if data is still fresh return state.lastRefresh != null && DateTime.now().difference(state.lastRefresh!) < Duration(minutes: 5); } @override Future reduce() async { final data = await api.fetchData(); return state.copy(data: data, lastRefresh: DateTime.now()); } } ``` ## Role-Based Authorization Use `abortDispatch()` to implement role-based access control: ```dart abstract class AdminAction extends ReduxAction { @override bool abortDispatch() => state.user?.role != UserRole.admin; } class DeleteAllUsers extends AdminAction { @override Future reduce() async { // Only admins can reach this code await api.deleteAllUsers(); return state.copy(users: []); } } ``` ## Conditional Feature Actions Prevent actions when features are disabled: ```dart class UsePremiumFeature extends ReduxAction { @override bool abortDispatch() => !state.user!.isPremium; @override AppState? reduce() { // Premium-only functionality return state.copy(/* ... */); } } ``` ## Built-in Mixin: AbortWhenNoInternet AsyncRedux provides `AbortWhenNoInternet`, a mixin that silently aborts actions when there's no internet connection: ```dart class FetchLatestNews extends AppAction with AbortWhenNoInternet { @override Future reduce() async { // Only runs if internet is available final news = await api.fetchNews(); return state.copy(news: news); } } ``` Key characteristics of `AbortWhenNoInternet`: - No error dialogs are shown - No exceptions are thrown - The action is silently cancelled - Only checks if device internet is on/off (not server availability) Compare with `CheckInternet` which shows an error dialog instead of silently aborting. ## abortDispatch() vs Throwing in before() Choose the right approach for your use case: | Approach | `after()` runs? | Shows error? | Use when | |--------------------------------|-----------------|------------------------|----------------------| | `abortDispatch()` returns true | No | No | Silently skip action | | Throw in `before()` | Yes | Yes (if UserException) | Show error to user | ```dart // Silent abort - user doesn't know action was skipped class SilentRefresh extends ReduxAction { @override bool abortDispatch() => state.isOffline; // ... } // Visible error - user sees message class ExplicitRefresh extends ReduxAction { @override void before() { if (state.isOffline) { throw UserException('Cannot refresh while offline'); } } // ... } ``` ## When to Use abortDispatch() **Good use cases:** - Authentication guards (action requires logged-in user) - Authorization checks (action requires specific role/permission) - Feature flags (action only for premium users) - Freshness checks (don't refetch if data is recent) - Maintenance mode (disable certain actions globally) - Idempotency (skip if action's effect already applied) **Consider alternatives when:** - You want the user to see an error message (throw `UserException` in `before()`) - You need cleanup code to run (use `before()` + `after()` pattern) - You're implementing rate limiting (use `Throttle` or `Debounce` mixins) - You're preventing duplicate dispatches (use `NonReentrant` mixin) ## Complete Example ```dart // Base action with common abort logic abstract class AppAction extends ReduxAction { @override bool abortDispatch() { // Global maintenance mode check if (state.maintenanceMode) return true; return false; } } // Authenticated action that also checks maintenance mode abstract class AuthenticatedAction extends AppAction { @override bool abortDispatch() { // Check parent conditions first if (super.abortDispatch()) return true; // Then check authentication return state.currentUser == null; } } // Admin action with full authorization chain abstract class AdminAction extends AuthenticatedAction { @override bool abortDispatch() { if (super.abortDispatch()) return true; return state.currentUser?.role != UserRole.admin; } } // Concrete action using the hierarchy class BanUser extends AdminAction { final String userId; BanUser(this.userId); @override Future reduce() async { // Only reaches here if: // 1. Not in maintenance mode // 2. User is logged in // 3. User is an admin await api.banUser(userId); return state.copy( users: state.users.where((u) => u.id != userId).toList(), ); } } ``` ## Important Notes - `abortDispatch()` is checked before `before()`, `reduce()`, and `after()` - When aborted, no state changes occur - The action is silently skipped—no errors are thrown or logged by default - Use this feature judiciously; the documentation warns it's "a powerful feature" that should only be used "if you are sure it is the right solution" ## References URLs from the documentation: - https://asyncredux.com/flutter/advanced-actions/aborting-the-dispatch - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/internet-mixins - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/basics/action-simplification ================================================ FILE: .claude/skills/asyncredux-action-status/SKILL.md ================================================ --- name: asyncredux-action-status description: Checks an AsyncRedux (Flutter) action's completion status using ActionStatus right after the dispatch returns. Use only when you need to know whether an action completed, whether it failed with an error, what error it produced, or how to navigate based on success or failure. --- # ActionStatus in AsyncRedux The `ActionStatus` object provides information about whether an action completed successfully or encountered errors. It is returned by `dispatchAndWait()` and related methods. ## Getting ActionStatus Use `dispatchAndWait()` to get the status after an action completes: ```dart var status = await dispatchAndWait(MyAction()); ``` From within an action, you can also use: ```dart var status = await dispatchAndWait(SomeOtherAction()); ``` ## ActionStatus Properties ### Completion Status - **`isCompleted`**: Returns `true` if the action has finished executing (whether successful or failed) - **`isCompletedOk`**: Returns `true` if the action finished without errors in both `before()` and `reduce()` methods - **`isCompletedFailed`**: Returns `true` if the action encountered errors (opposite of `isCompletedOk`) ### Error Information - **`originalError`**: The error originally thrown by `before()` or `reduce()`, before any modification - **`wrappedError`**: The error after processing by the action's `wrapError()` method ### Execution Tracking These properties track which lifecycle methods have completed: - **`hasFinishedMethodBefore`**: Returns `true` if the `before()` method completed - **`hasFinishedMethodReduce`**: Returns `true` if the `reduce()` method completed - **`hasFinishedMethodAfter`**: Returns `true` if the `after()` method completed Note: The execution tracking properties are primarily meant for testing and debugging. In production code, focus on `isCompletedOk` and `isCompletedFailed`. ## Common Use Cases ### Conditional Navigation After Success The most common production use is checking if an action succeeded before navigating: ```dart // In a widget callback Future _onSavePressed() async { var status = await context.dispatchAndWait(SaveFormAction()); if (status.isCompletedOk) { Navigator.pop(context); } } ``` Another example with push navigation: ```dart Future _onLoginPressed() async { var status = await context.dispatchAndWait(LoginAction( email: emailController.text, password: passwordController.text, )); if (status.isCompletedOk) { Navigator.pushReplacementNamed(context, '/home'); } // If failed, the error will be shown via UserExceptionDialog } ``` ### Testing Action Errors Use ActionStatus to verify that actions throw expected errors: ```dart test('MyAction fails with invalid input', () async { var store = Store(initialState: AppState.initial()); var status = await store.dispatchAndWait(MyAction(value: -1)); expect(status.isCompletedFailed, isTrue); expect(status.wrappedError, isA()); expect((status.wrappedError as UserException).msg, "Value must be positive"); }); ``` ### Testing Action Success ```dart test('SaveAction completes successfully', () async { var store = Store(initialState: AppState.initial()); var status = await store.dispatchAndWait(SaveAction(data: validData)); expect(status.isCompletedOk, isTrue); expect(store.state.saved, isTrue); }); ``` ### Checking Original vs Wrapped Error When your action uses `wrapError()` to transform errors, you can inspect both: ```dart class MyAction extends AppAction { @override Future reduce() async { throw Exception('Network error'); } @override Object? wrapError(Object error, StackTrace stackTrace) { return UserException('Could not save. Please try again.'); } } // In test: var status = await store.dispatchAndWait(MyAction()); expect(status.originalError, isA()); // The original Exception expect(status.wrappedError, isA()); // The wrapped UserException ``` ## Action Lifecycle and Status The action lifecycle runs in this order: 1. `before()` - Runs first, can be used for preconditions 2. `reduce()` - Runs second (only if `before()` succeeded) 3. `after()` - Runs last, always executes (like a finally block) The `isCompletedOk` property is `true` only if both `before()` and `reduce()` completed without errors. Note that errors in `after()` do not affect `isCompletedOk`. If `before()` throws an error, `reduce()` will not run, but `after()` will still execute. ## Best Practices 1. **Use state changes for UI updates**: In production, prefer checking state changes rather than action status. Reserve ActionStatus for cases where you need to perform side effects (like navigation) based on success/failure. 2. **Use `isCompletedOk` for navigation**: The common pattern is to navigate only after an action succeeds: ```dart if (status.isCompletedOk) Navigator.pop(context); ``` 3. **Use `wrappedError` in tests**: When testing error handling, check `wrappedError` to see what the user will actually see (after `wrapError()` processing). 4. **Use `originalError` for debugging**: When you need to see the underlying error before any transformation, use `originalError`. ## References URLs from the documentation: - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/miscellaneous/navigation - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://asyncredux.com/flutter/testing/testing-user-exceptions ================================================ FILE: .claude/skills/asyncredux-actions-no-state-change/SKILL.md ================================================ --- name: asyncredux-actions-no-state-change description: Creates AsyncRedux (Flutter) actions that return null from reduce() to not change the state. Such actions can still do side effects, dispatch other actions, or do nothing. --- # Actions That Don't Change State In AsyncRedux, returning a new state from reducers is **optional**. When you don't need to modify the application state, return `null` to keep the current state unchanged. ## Basic Pattern Return `null` from `reduce()` when no state modification is needed: ```dart class MyAction extends ReduxAction { AppState? reduce() { // Perform side effects here return null; // State remains unchanged } } ``` ## Conditional State Updates Only update state when certain conditions are met: ```dart class GetAmount extends ReduxAction { Future reduce() async { int amount = await getAmount(); if (amount == 0) return null; // No change needed else return state.copy(counter: state.counter + amount); } } ``` ## Coordinating Other Actions Actions that dispatch other actions but don't modify state directly: ```dart class InitAction extends ReduxAction { AppState? reduce() { dispatch(ReadDatabaseAction()); dispatch(StartTimersAction()); dispatch(TurnOnListenersAction()); return null; // This action doesn't change state itself } } ``` ## Triggering External Services Call external services without modifying app state: ```dart class SendNotification extends ReduxAction { final String message; SendNotification(this.message); Future reduce() async { await notificationService.send(message); return null; } } ``` ## Navigation Actions Trigger navigation as a side effect: ```dart class GoToSettings extends ReduxAction { AppState? reduce() { dispatch(NavigateAction.pushNamed('/settings')); return null; } } ``` ## Key Points 1. Actions that do return a new state can **also** do side effects and dispatch other actions. 2. **Return type matters**: Use `AppState?` for sync, `Future` for async 3. **Null means no change**: The store keeps its current state ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/changing-state-is-optional - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/basics/sync-actions - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer ================================================ FILE: .claude/skills/asyncredux-async-actions/SKILL.md ================================================ --- name: asyncredux-async-actions description: Creates AsyncRedux (Flutter) asynchronous actions for API calls, database operations, and other async work. --- # AsyncRedux Async Actions ## Basic Async Action Structure An action becomes asynchronous when its `reduce()` method returns `Future` instead of `AppState?`. Use this for database access, API calls, file operations, or any work requiring `await`. ```dart class FetchUser extends ReduxAction { @override Future reduce() async { final user = await api.fetchUser(); return state.copy(user: user); } } ``` Unlike traditional Redux requiring middleware, AsyncRedux makes it simple: return a `Future` and it works. ## Critical Rule: Every Path Must Have await If the action is async (returns a Future) and changes the state (returns a non-null state), the framework requires that, **all execution paths contain at least one `await`**. Never declare `Future` if you don't actually await something. ### Valid Patterns ```dart // Simple async with await Future reduce() async { final data = await fetchData(); return state.copy(data: data); } // Using microtask (minimum valid await) Future reduce() async { await microtask; return state.copy(timestamp: DateTime.now()); } // Conditional - both paths have await Future reduce() async { if (state.needsRefresh) { return await fetchAndUpdate(); } else return await validateCurrent(); } // Always returns null Future reduce() async { if (state.needsRefresh) { await fetchAndUpdate(); } return null; } ``` ### Invalid Patterns (Will Cause Issues) ```dart // WRONG: No await at all Future reduce() async { return state.copy(counter: state.counter + 1); } // WRONG: await only on some paths Future reduce() async { if (condition) { return await fetchData(); } return state; // No await on this path! } // WRONG: Calling async function without await Future reduce() async { someAsyncFunction(); // Not awaited return state; } ``` ## Using assertUncompletedFuture() For complex reducers with multiple code paths, add `assertUncompletedFuture()` before the final return. This catches violations at runtime during development: ```dart class ComplexAction extends ReduxAction { @override Future reduce() async { if (state.cacheValid) { // Complex logic that might accidentally skip await return processCache(); } final data = await fetchFromServer(); final processed = transform(data); assertUncompletedFuture(); // Validates at least one await occurred return state.copy(data: processed); } } ``` ## State Changes During Async Operations The `state` getter can change after every `await` because other actions may modify state while yours is waiting: ```dart class AsyncAction extends ReduxAction { @override Future reduce() async { print(state.counter); // e.g., 5 await someSlowOperation(); // state.counter might now be different (e.g., 10) // if another action modified it during the await print(state.counter); return state.copy(counter: state.counter + 1); } } ``` ### Using initialState for Comparison Use `initialState` to access the state as it was when the action was dispatched (never changes): ```dart class SafeIncrement extends ReduxAction { @override Future reduce() async { final originalCounter = initialState.counter; await validateWithServer(); // Check if state changed while we were waiting if (state.counter != originalCounter) { // State was modified by another action return null; // Abort our change } return state.copy(counter: state.counter + 1); } } ``` ## Dispatching Async Actions ### Fire and Forget Use `dispatch()` when you don't need to wait for completion: ```dart context.dispatch(FetchUser()); // Returns immediately, action runs in background ``` ### Wait for Completion Use `dispatchAndWait()` to await the action's completion: ```dart await context.dispatchAndWait(FetchUser()); // Continues only after action finishes AND state changes print('User loaded: ${context.state.user.name}'); ``` ### Dispatch Multiple in Parallel ```dart // Fire all, don't wait context.dispatchAll([FetchUser(), FetchSettings(), FetchNotifications()]); // Fire all and wait for all to complete await context.dispatchAndWaitAll([FetchUser(), FetchSettings()]); ``` ## Showing Loading States Use `isWaiting()` to show spinners while async actions run: ```dart Widget build(BuildContext context) { if (context.isWaiting(FetchUser)) return CircularProgressIndicator(); else return Text('Hello, ${context.state.user.name}'); } ``` ## Error Handling Throw `UserException` for user-facing errors: ```dart class FetchUser extends ReduxAction { @override Future reduce() async { final response = await api.fetchUser(); if (response.statusCode == 404) throw UserException('User not found.'); if (response.statusCode != 200) throw UserException('Failed to load user. Please try again.'); return state.copy(user: response.data); } } ``` Check for failures in widgets: ```dart Widget build(BuildContext context) { if (context.isFailed(FetchUser)) { return Text('Error: ${context.exceptionFor(FetchUser)?.message}'); } // ... } ``` ## Complete Example ```dart // Async action with proper error handling class LoadProducts extends ReduxAction { @override Future reduce() async { try { final products = await api.fetchProducts(); return state.copy(products: products, productsLoaded: true); } catch (e) { throw UserException('Could not load products. Check your connection.'); } } } // Widget showing all three states Widget build(BuildContext context) { // Loading state if (context.isWaiting(LoadProducts)) { return Center(child: CircularProgressIndicator()); } // Error state if (context.isFailed(LoadProducts)) { return Center( child: Column( children: [ Text(context.exceptionFor(LoadProducts)?.message ?? 'Error'), ElevatedButton( onPressed: () => context.dispatch(LoadProducts()), child: Text('Retry'), ), ], ), ); } // Success state return ListView.builder( itemCount: context.state.products.length, itemBuilder: (_, i) => ProductTile(context.state.products[i]), ); } ``` ## Return Type Warning Never return `FutureOr` directly. AsyncRedux must know if the action is sync or async: ```dart // CORRECT Future reduce() async { ... } // CORRECT AppState? reduce() { ... } // WRONG - throws StoreException FutureOr reduce() { ... } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/wait-fail-succeed ================================================ FILE: .claude/skills/asyncredux-base-action/SKILL.md ================================================ --- name: asyncredux-base-action description: Create a custom base action class for your app. Covers adding getter shortcuts to state parts, adding selector methods, implementing shared wrapError logic, and establishing project-wide action conventions. --- # Creating a Custom Base Action Class Every AsyncRedux application should define an abstract base action class that all actions extend. This provides a central place for: - Convenient getter shortcuts to state parts - Selector methods for common queries - Shared error handling logic - Type-safe environment access - Project-wide conventions ## Basic Base Action Setup Create an abstract class extending `ReduxAction`. The recomended name is `AppAction`, in file `app_action.dart`: ```dart abstract class AppAction extends ReduxAction { // All your actions will extend this class } ``` Then extend `AppAction` instead of `ReduxAction` in all your actions: ```dart class IncrementCounter extends AppAction { @override AppState reduce() => state.copy(counter: state.counter + 1); } ``` ## Adding Getter Shortcuts to State Parts When your state has nested objects, add getters to simplify access: ```dart abstract class AppAction extends ReduxAction { // Shortcuts to nested state parts User get user => state.user; Settings get settings => state.settings; IList get todos => state.todos; Cart get cart => state.cart; } ``` Now actions can write cleaner code: ```dart class UpdateUserName extends AppAction { final String name; UpdateUserName(this.name); @override AppState reduce() { // Instead of: state.user.name // You can write: user.name return state.copy(user: user.copy(name: name)); } } ``` ## Adding Selector Methods For common data lookups, add selector methods directly to your base action: ```dart abstract class AppAction extends ReduxAction { // Getters for state parts User get user => state.user; IList get items => state.items; // Selector methods Item? findItemById(String id) => items.firstWhereOrNull((item) => item.id == id); List get completedItems => items.where((item) => item.isCompleted).toList(); bool get isLoggedIn => user.isAuthenticated; } ``` Actions can then use these selectors: ```dart class MarkItemComplete extends AppAction { final String itemId; MarkItemComplete(this.itemId); @override AppState reduce() { final item = findItemById(itemId); if (item == null) return null; // No change return state.copy( items: items.replaceFirstWhere( (i) => i.id == itemId, item.copy(isCompleted: true), ), ); } } ``` ### Using a Separate Selector Class For most applications, it's better to use instead a dedicated selector class to keep the base action clean: ```dart class ActionSelect { final AppState state; ActionSelect(this.state); Item? findById(String id) => state.items.firstWhereOrNull((item) => item.id == id); List get completed => state.items.where((item) => item.isCompleted).toList(); List get pending => state.items.where((item) => !item.isCompleted).toList(); } abstract class AppAction extends ReduxAction { ActionSelect get select => ActionSelect(state); } ``` These namespaces selectors under `select`, enabling IDE autocompletion: ```dart class ProcessItem extends AppAction { final String itemId; ProcessItem(this.itemId); @override AppState reduce() { // IDE autocomplete shows: select.findById, select.completed, etc. final item = select.findById(itemId); // ... } } ``` ## Type-Safe Environment Access For dependency injection, override the `env` getter in your base action: ```dart class Environment { final ApiClient api; final AuthService auth; final AnalyticsService analytics; Environment({ required this.api, required this.auth, required this.analytics, }); } abstract class AppAction extends ReduxAction { // Type-safe access to environment @override Environment get env => super.env as Environment; // Convenience getters for common services ApiClient get api => env.api; AuthService get auth => env.auth; } ``` Actions can then use services directly: ```dart class FetchUserProfile extends AppAction { @override Future reduce() async { // Uses the api getter from base action final profile = await api.getUserProfile(); return state.copy(user: profile); } } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/advanced-actions/action-selectors - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/advanced-actions/wrapping-the-reducer - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/aborting-the-dispatch - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/basics/state - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/miscellaneous/business-logic - https://asyncredux.com/flutter/miscellaneous/dependency-injection ================================================ FILE: .claude/skills/asyncredux-before-after/SKILL.md ================================================ --- name: asyncredux-before-after description: Implement action lifecycle methods `before()` and `after()`. Covers running precondition checks, showing/hiding modal barriers, cleanup logic in `after()`, and understanding that `after()` always runs (like a finally block). --- # AsyncRedux Before and After Methods ## Action Lifecycle Overview Every `ReduxAction` has three lifecycle methods that execute in order: 1. `before()` - Runs first, before the reducer 2. `reduce()` - The main reducer (required) 3. `after()` - Runs last, always executes Only `reduce()` is required. The `before()` and `after()` methods are optional hooks for managing side effects. ## The before() Method The `before()` method executes before the reducer runs. It can be synchronous or asynchronous. ### Synchronous before() ```dart class MyAction extends ReduxAction { @override void before() { // Runs synchronously before reduce() print('Action starting'); } @override AppState? reduce() { return state.copy(counter: state.counter + 1); } } ``` ### Asynchronous before() ```dart class MyAction extends ReduxAction { @override Future before() async { // Runs asynchronously before reduce() await validatePermissions(); } @override Future reduce() async { final data = await fetchData(); return state.copy(data: data); } } ``` ### Precondition Checks in before() If `before()` throws an error, `reduce()` will NOT run. This makes it ideal for validation: ```dart class FetchUserData extends ReduxAction { @override Future before() async { if (!await hasInternetConnection()) { throw UserException('No internet connection'); } } @override Future reduce() async { // Only runs if before() completed without error final user = await api.fetchUser(); return state.copy(user: user); } } ``` ### Common before() Use Cases - Validate preconditions (authentication, permissions) - Check network connectivity - Show loading indicators or modal barriers - Log action start for analytics - Dispatch prerequisite actions ## The after() Method The `after()` method executes after the reducer completes. Its key property: **it always runs, even if `before()` or `reduce()` throws an error**. This makes it similar to a `finally` block. ### Basic after() ```dart class MyAction extends ReduxAction { @override AppState? reduce() { return state.copy(counter: state.counter + 1); } @override void after() { // Always runs, regardless of success or failure print('Action completed'); } } ``` ### Guaranteed Cleanup Because `after()` always runs, it's perfect for cleanup operations: ```dart class SaveDocument extends ReduxAction { @override Future before() async { dispatch(ShowSavingIndicatorAction(true)); } @override Future reduce() async { await api.saveDocument(state.document); return state.copy(lastSaved: DateTime.now()); } @override void after() { // Hides indicator even if save fails dispatch(ShowSavingIndicatorAction(false)); } } ``` ### Important: Never Throw from after() The `after()` method should never throw errors. Any exception thrown from `after()` will appear asynchronously in the console and cannot be caught normally: ```dart // WRONG - Don't throw in after() @override void after() { if (someCondition) { throw Exception('This will cause problems'); } } // CORRECT - Handle errors gracefully @override void after() { try { cleanup(); } catch (e) { // Log but don't throw logger.error('Cleanup failed: $e'); } } ``` ### Common after() Use Cases - Hide loading indicators or modal barriers - Close database connections or file handles - Release temporary resources - Log action completion for analytics - Dispatch follow-up actions ## Modal Barrier Pattern A common pattern is showing a modal barrier (blocking overlay) during async operations: ```dart class MyAction extends ReduxAction { @override Future reduce() async { String description = await read(Uri.http("numbersapi.com", "${state.counter}")); return state.copy(description: description); } @override void before() => dispatch(BarrierAction(true)); @override void after() => dispatch(BarrierAction(false)); } ``` The `BarrierAction` would update state to show/hide a loading overlay: ```dart class BarrierAction extends ReduxAction { final bool show; BarrierAction(this.show); @override AppState reduce() => state.copy(showBarrier: show); } ``` ## Creating Reusable Mixins For patterns you use repeatedly, create a mixin: ```dart mixin Barrier on ReduxAction { @override void before() { super.before(); dispatch(BarrierAction(true)); } @override void after() { dispatch(BarrierAction(false)); super.after(); } } ``` Then apply it to any action: ```dart class FetchData extends ReduxAction with Barrier { @override Future reduce() async { // Barrier shown automatically before this runs final data = await api.fetchData(); return state.copy(data: data); // Barrier hidden automatically after (even on error) } } ``` ### Multiple Mixins You can combine multiple mixins: ```dart class ImportantAction extends ReduxAction with Barrier, NonReentrant { @override Future reduce() async { // Has both modal barrier AND prevents duplicate dispatches return state; } } ``` ## Error Handling Flow Understanding how errors interact with the lifecycle: ```dart class MyAction extends ReduxAction { @override Future before() async { // If this throws, reduce() is skipped, after() still runs } @override Future reduce() async { // If this throws, state is not changed, after() still runs } @override void after() { // ALWAYS runs regardless of errors above } } ``` ### Checking What Completed Use `ActionStatus` to determine which methods finished: ```dart var status = await dispatchAndWait(MyAction()); if (status.hasFinishedMethodBefore) { print('before() completed'); } if (status.hasFinishedMethodReduce) { print('reduce() completed'); } if (status.hasFinishedMethodAfter) { print('after() completed'); } if (status.isCompletedOk) { print('Both before() and reduce() completed without errors'); } if (status.isCompletedFailed) { print('Error: ${status.originalError}'); } ``` ## Relationship with abortDispatch() If `abortDispatch()` returns `true`, none of the lifecycle methods run: ```dart class MyAction extends ReduxAction { @override bool abortDispatch() => state.user == null; @override void before() { // Skipped if abortDispatch() returns true } @override AppState? reduce() { // Skipped if abortDispatch() returns true } @override void after() { // Skipped if abortDispatch() returns true } } ``` ## Complete Example ```dart class SubmitForm extends ReduxAction { final String formData; SubmitForm(this.formData); @override Future before() async { // Validate preconditions if (state.user == null) { throw UserException('Please log in first'); } if (!await checkInternetConnection()) { throw UserException('No internet connection'); } // Show loading state dispatch(SetSubmittingAction(true)); } @override Future reduce() async { final result = await api.submitForm(formData); return state.copy( lastSubmission: result, submissionCount: state.submissionCount + 1, ); } @override void after() { // Always hide loading state, even on error dispatch(SetSubmittingAction(false)); // Log completion analytics.log('form_submitted'); } } ``` ## Built-in Mixins Using before() and after() Several AsyncRedux mixins use these methods internally: | Mixin | Uses before() | Uses after() | Purpose | |-------|--------------|--------------|---------| | `CheckInternet` | Yes | No | Verifies connectivity, shows dialog if offline | | `AbortWhenNoInternet` | Yes | No | Silently aborts if offline | | `Throttle` | No | Yes | Limits execution frequency | | `NonReentrant` | Yes | Yes | Prevents duplicate dispatches | | `Retry` | No | Yes | Retries on failure | | `Debounce` | No | No | Waits for input pause (uses `wrapReduce`) | When using these mixins, be aware that they may already override `before()` or `after()`. Call `super.before()` and `super.after()` if you need to combine behaviors. ## References URLs from the documentation: - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/aborting-the-dispatch - https://asyncredux.com/flutter/advanced-actions/wrapping-the-reducer ================================================ FILE: .claude/skills/asyncredux-check-internet-mixin/SKILL.md ================================================ --- name: asyncredux-check-internet-mixin description: Add the CheckInternet mixin to ensure network connectivity before action execution. Covers automatic error dialogs, combining with NoDialog for custom UI handling, and AbortWhenNoInternet for silent abort. --- # CheckInternet Mixin The `CheckInternet` mixin verifies device connectivity before an action executes. If there's no internet connection, the action aborts and displays a dialog with the message: "There is no Internet. Please, verify your connection." ## Basic Usage ```dart class LoadText extends AppAction with CheckInternet { Future reduce() async { var response = await http.get('https://api.example.com/text'); return state.copy(text: response.body); } } ``` The mixin works by overriding the `before()` method. If the device lacks connectivity, it throws a `UserException` which triggers the standard error dialog. ## Customizing the Error Message Override `connectionException()` to return a custom `UserException`: ```dart class LoadText extends AppAction with CheckInternet { @override UserException connectionException(UserException error) { return UserException('Unable to load data. Check your connection.'); } Future reduce() async { var response = await http.get('https://api.example.com/text'); return state.copy(text: response.body); } } ``` ## NoDialog Modifier Use `NoDialog` alongside `CheckInternet` to suppress the automatic error dialog. This allows you to handle connectivity failures in your widgets using `isFailed()` and `exceptionFor()`: ```dart class LoadText extends AppAction with CheckInternet, NoDialog { Future reduce() async { var response = await http.get('https://api.example.com/text'); return state.copy(text: response.body); } } ``` Then handle the error in your widget: ```dart Widget build(BuildContext context) { if (context.isWaiting(LoadText)) { return CircularProgressIndicator(); } if (context.isFailed(LoadText)) { var exception = context.exceptionFor(LoadText); return Text('Error: ${exception?.message}'); } return Text(context.state.text); } ``` ## AbortWhenNoInternet Use `AbortWhenNoInternet` for silent failure when offline. The action aborts without throwing errors or displaying dialogs—as if it had never been dispatched: ```dart class RefreshData extends AppAction with AbortWhenNoInternet { Future reduce() async { var response = await http.get('https://api.example.com/data'); return state.copy(data: response.body); } } ``` This is useful for background refreshes or non-critical operations where user notification isn't needed. ## UnlimitedRetryCheckInternet This mixin combines three capabilities: internet verification, unlimited retry with exponential backoff, and non-reentrant behavior. It's ideal for essential operations like loading startup data: ```dart class LoadAppStartupData extends AppAction with UnlimitedRetryCheckInternet { Future reduce() async { var response = await http.get('https://api.example.com/startup'); return state.copy(startupData: response.body); } } ``` Default retry parameters: - Initial delay: 350ms - Multiplier: 2 - Maximum delay with internet: 5 seconds - Maximum delay without internet: 1 second Track retry attempts via the `attempts` getter and customize logging through `printRetries()`. ## Mixin Compatibility Important compatibility rules: - `CheckInternet` and `AbortWhenNoInternet` are **incompatible** with each other - Neither `CheckInternet` nor `AbortWhenNoInternet` can be combined with `UnlimitedRetryCheckInternet` - `CheckInternet` works well with `Retry`, `NonReentrant`, `Throttle`, `Debounce`, and optimistic mixins ## Testing Internet Connectivity Two methods for simulating connectivity in tests: **Per-action simulation** - Override `internetOnOffSimulation` within specific actions: ```dart class LoadText extends AppAction with CheckInternet { @override bool? get internetOnOffSimulation => false; // Simulate offline Future reduce() async { // ... } } ``` **Global simulation** - Set `forceInternetOnOffSimulation` on the store: ```dart var store = Store( initialState: AppState.initialState(), ); store.forceInternetOnOffSimulation = false; // All actions see no internet ``` ## Limitations These mixins only detect device connectivity status. They cannot verify: - Internet provider functionality - Server availability - API endpoint reachability For server-specific connectivity checks, implement additional validation in your action's `reduce()` method or `before()` method. ## References URLs from the documentation: - https://asyncredux.com/flutter/advanced-actions/internet-mixins - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/aborting-the-dispatch - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/basics/wait-fail-succeed - https://asyncredux.com/flutter/testing/mocking ================================================ FILE: .claude/skills/asyncredux-connector-pattern/SKILL.md ================================================ --- name: asyncredux-connector-pattern description: Implement the Connector pattern for separating smart and dumb widgets. Covers creating StoreConnector widgets, implementing VmFactory and Vm classes, building view-models, and optimizing rebuilds with view-model equality. --- ## Overview The connector pattern separates store access logic from UI presentation. Instead of widgets directly accessing the store via `context.state` and `context.dispatch()`, a "smart" connector widget extracts store data and passes it to a "dumb" presentational widget through constructor parameters. ## Why Use the Connector Pattern? 1. **Testing simplification** - Test UI widgets without creating a Redux store by passing mock data 2. **Separation of concerns** - UI widgets focus on appearance; connectors handle business logic 3. **Reusability** - Presentational widgets function independently of Redux 4. **Code clarity** - Widget code is not cluttered with state access and transformation logic 5. **Optimized rebuilds** - Only rebuild when the view-model changes ## The Three Components ### 1. ViewModel (Vm) Contains only the data the UI widget requires. Extends `Vm` and lists equality fields: ```dart class CounterViewModel extends Vm { final int counter; final String description; final VoidCallback onIncrement; CounterViewModel({ required this.counter, required this.description, required this.onIncrement, }) : super(equals: [counter, description]); } ``` The `equals` list tells AsyncRedux which fields to compare when deciding whether to rebuild. Callbacks (like `onIncrement`) should NOT be included in `equals`. ### 2. VmFactory Transforms store state into a view-model. Extends `VmFactory` and implements `fromStore()`: ```dart class CounterFactory extends VmFactory { CounterFactory(connector) : super(connector); @override CounterViewModel fromStore() => CounterViewModel( counter: state.counter, description: state.description, onIncrement: () => dispatch(IncrementAction()), ); } ``` The factory has access to: - `state` - The store state when the factory was created - `dispatch()` - To dispatch actions from callbacks - `dispatchSync()` - For synchronous dispatch - `connector` - Reference to the parent connector widget ### 3. StoreConnector Bridges the store and UI widget: ```dart class CounterConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => CounterFactory(this), builder: (BuildContext context, CounterViewModel vm) => CounterWidget( counter: vm.counter, description: vm.description, onIncrement: vm.onIncrement, ), ); } } ``` The "dumb" widget receives data through constructor parameters: ```dart class CounterWidget extends StatelessWidget { final int counter; final String description; final VoidCallback onIncrement; const CounterWidget({ required this.counter, required this.description, required this.onIncrement, }); @override Widget build(BuildContext context) { return Column( children: [ Text('$counter'), Text(description), ElevatedButton( onPressed: onIncrement, child: Text('Increment'), ), ], ); } } ``` ## Rebuild Optimization Each time an action changes the store state, `StoreConnector` compares the new view-model with the previous one. It only rebuilds if they differ (based on the `equals` list). To prevent rebuilds even when state changes, use `notify: false`: ```dart dispatch(MyAction(), notify: false); ``` ## Advanced Factory Techniques ### Accessing Connector Properties Pass data from the connector widget to the factory: ```dart class UserConnector extends StatelessWidget { final int userId; const UserConnector({required this.userId}); @override Widget build(BuildContext context) { return StoreConnector( vm: () => UserFactory(this), builder: (context, vm) => UserWidget(user: vm.user), ); } } class UserFactory extends VmFactory { UserFactory(connector) : super(connector); @override UserViewModel fromStore() => UserViewModel( // Access connector.userId here user: state.users.firstWhere((u) => u.id == connector.userId), ); } ``` ### state vs currentState() Inside the factory: - `state` - The state when the factory was created (final, won't change) - `currentState()` - The current store state at the moment of the call These usually match, but diverge in callbacks after `dispatchSync()`: ```dart @override UserViewModel fromStore() => UserViewModel( onSave: () { dispatchSync(SaveAction()); // state still has old value // currentState() has new value after SaveAction }, ); ``` ### Using the vm Getter in Callbacks Access already-computed view-model fields in callbacks to avoid redundant calculations: ```dart @override UserViewModel fromStore() => UserViewModel( name: state.user.name, onSave: () { // Use vm.name instead of recalculating from state print('Saving user: ${vm.name}'); dispatch(SaveAction(vm.name)); }, ); ``` **Note:** The `vm` getter is only available after `fromStore()` completes. Use it in callbacks, not during view-model construction. ### Base Factory Pattern Create a base factory to reduce boilerplate: ```dart abstract class BaseFactory extends VmFactory { BaseFactory(T connector) : super(connector); // Common getters User get user => state.user; Settings get settings => state.settings; } class MyFactory extends BaseFactory { MyFactory(connector) : super(connector); @override MyViewModel fromStore() => MyViewModel( user: user, // Uses inherited getter ); } ``` ## Nullable View-Models When you cannot generate a valid view-model (e.g., data still loading), return null: ```dart class HomeConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( // Nullable type vm: () => HomeFactory(this), builder: (BuildContext context, HomeViewModel? vm) { // Nullable param return (vm == null) ? Text("User not logged in") : HomePage(user: vm.user); }, ); } } class HomeFactory extends VmFactory { HomeFactory(connector) : super(connector); @override HomeViewModel? fromStore() { // Nullable return return (state.user == null) ? null : HomeViewModel(user: state.user!); } } ``` ## Migrating from flutter_redux If migrating from `flutter_redux`, you can use the `converter` parameter instead of `vm`: ```dart class MyConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( converter: (store) => ViewModel.fromStore(store), builder: (context, vm) => MyWidget(name: vm.name), ); } } class ViewModel extends Vm { final String name; final VoidCallback onSave; ViewModel({required this.name, required this.onSave}) : super(equals: [name]); static ViewModel fromStore(Store store) { return ViewModel( name: store.state.name, onSave: () => store.dispatch(SaveAction()), ); } } ``` Note: `vm` and `converter` are mutually exclusive. The `vm` approach is recommended for new code. ## Debugging Rebuilds To observe when connectors rebuild, pass a `modelObserver` to the store: ```dart var store = Store( initialState: AppState.initialState(), modelObserver: DefaultModelObserver(), ); ``` Add `debug: this` to StoreConnector for connector type names in logs: ```dart StoreConnector( debug: this, vm: () => Factory(this), builder: (context, vm) => MyWidget(vm: vm), ); ``` Override `toString()` in your ViewModel for custom diagnostic output: ```dart class MyViewModel extends Vm { final int counter; MyViewModel({required this.counter}) : super(equals: [counter]); @override String toString() => 'MyViewModel{counter: $counter}'; } ``` Console output shows rebuild information: ``` Model D:1 R:1 = Rebuild:true, Connector:MyWidgetConnector, Model:MyViewModel{counter: 5} ``` ## Testing View-Models Use `Vm.createFrom()` to test view-models in isolation: ```dart test('view-model properties', () { var store = Store(initialState: AppState(name: "Mary")); var vm = Vm.createFrom(store, MyFactory()); expect(vm.name, "Mary"); }); test('view-model callbacks dispatch actions', () async { var store = Store(initialState: AppState(name: "Mary")); var vm = Vm.createFrom(store, MyFactory()); vm.onChangeName("Bill"); await store.waitActionType(ChangeNameAction); expect(store.state.name, "Bill"); }); ``` **Important:** `Vm.createFrom()` can only be called once per factory instance. Create a new factory for each test. ## Complete Example ```dart // State class AppState { final int counter; final String description; AppState({required this.counter, required this.description}); AppState copy({int? counter, String? description}) => AppState( counter: counter ?? this.counter, description: description ?? this.description, ); } // Action class IncrementAction extends ReduxAction { @override AppState reduce() => state.copy(counter: state.counter + 1); } // View-Model class CounterViewModel extends Vm { final int counter; final String description; final VoidCallback onIncrement; CounterViewModel({ required this.counter, required this.description, required this.onIncrement, }) : super(equals: [counter, description]); } // Factory class CounterFactory extends VmFactory { CounterFactory(connector) : super(connector); @override CounterViewModel fromStore() => CounterViewModel( counter: state.counter, description: state.description, onIncrement: () => dispatch(IncrementAction()), ); } // Connector (Smart Widget) class CounterConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => CounterFactory(this), builder: (context, vm) => CounterWidget( counter: vm.counter, description: vm.description, onIncrement: vm.onIncrement, ), ); } } // Presentational Widget (Dumb Widget) class CounterWidget extends StatelessWidget { final int counter; final String description; final VoidCallback onIncrement; const CounterWidget({ required this.counter, required this.description, required this.onIncrement, }); @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('$counter', style: TextStyle(fontSize: 48)), Text(description), SizedBox(height: 20), ElevatedButton( onPressed: onIncrement, child: Text('Increment'), ), ], ); } } ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/connector/connector-pattern - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/connector/advanced-view-model - https://asyncredux.com/flutter/connector/cannot-generate-view-model - https://asyncredux.com/flutter/connector/migrating-from-flutter-redux - https://asyncredux.com/flutter/testing/testing-the-view-model - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/miscellaneous/observing-rebuilds ================================================ FILE: .claude/skills/asyncredux-debounce-mixin/SKILL.md ================================================ --- name: asyncredux-debounce-mixin description: Add the Debounce mixin to wait for user input pauses before acting. Covers setting the `debounce` duration, implementing search-as-you-type, and avoiding excessive API calls during rapid input. --- # Debounce Mixin The `Debounce` mixin delays action execution until after a period of inactivity. Each new dispatch resets the timer, so the action only runs once dispatching stops for the specified duration. This is ideal for search-as-you-type functionality and avoiding excessive API calls during rapid user input. ## Basic Usage Add the `Debounce` mixin to your action class: ```dart class SearchText extends AppAction with Debounce { final String searchTerm; SearchText(this.searchTerm); Future reduce() async { var response = await http.get( Uri.parse('https://example.com/?q=${Uri.encodeComponent(searchTerm)}') ); return state.copy(searchResult: response.body); } } ``` When the user types quickly, each keystroke dispatches `SearchText`. The mixin delays execution, and each new dispatch resets the timer. The API call only happens once the user stops typing for the debounce period. ## Setting the Debounce Duration The default debounce period is **333 milliseconds**. Override the `debounce` getter to customize: ```dart class SearchText extends AppAction with Debounce { final String searchTerm; SearchText(this.searchTerm); // Wait 1 second of inactivity before executing int get debounce => 1000; Future reduce() async { var response = await http.get( Uri.parse('https://example.com/?q=${Uri.encodeComponent(searchTerm)}') ); return state.copy(searchResult: response.body); } } ``` ## Custom Lock Builder By default, all instances of a debounced action share the same lock. Override `lockBuilder()` to create independent debounce periods for different action instances: ```dart class SearchField extends AppAction with Debounce { final String fieldId; final String searchTerm; SearchField(this.fieldId, this.searchTerm); // Each fieldId gets its own independent debounce timer Object? lockBuilder() => fieldId; Future reduce() async { // Search logic here } } ``` This enables multiple search fields to operate independently, each with their own debounce timer. ## Debounce vs Throttle These two mixins serve different purposes: | Mixin | Behavior | Best For | |-------|----------|----------| | **Throttle** | Runs immediately on first dispatch, then blocks subsequent dispatches for the period | Rate-limiting actions that should execute right away (e.g., refresh button) | | **Debounce** | Waits for quiet time, only runs after dispatches stop | Waiting for user to finish input (e.g., search-as-you-type) | **Throttle**: "Execute now, then wait before allowing again" **Debounce**: "Wait until activity stops, then execute" ## Mixin Compatibility Debounce **can** be combined with: - `CheckInternet` - `NoDialog` - `AbortWhenNoInternet` - `NonReentrant` - `Fresh` - `Throttle` Debounce **cannot** be combined with: - `Retry` - `UnlimitedRetries` - `UnlimitedRetryCheckInternet` - `OptimisticCommand` - `OptimisticSync` - `OptimisticSyncWithPush` - `ServerPush` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/control-mixins#debounce - https://asyncredux.com/flutter/advanced-actions/control-mixins#throttle - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/basics/events - https://asyncredux.com/flutter/advanced-actions/action-mixins#compatibility ================================================ FILE: .claude/skills/asyncredux-debugging/SKILL.md ================================================ --- name: asyncredux-debugging description: Debug AsyncRedux applications effectively. Covers printing state with store.state, checking actionsInProgress(), using ConsoleActionObserver, StateObserver for state change tracking, and tracking dispatchCount/reduceCount. --- # Debugging AsyncRedux Applications AsyncRedux provides several tools for debugging and monitoring your application's state, actions, and behavior during development. ## Inspecting Store State Access the current state directly from the store: ```dart // Direct state access print(store.state); // Access specific parts print(store.state.user.name); print(store.state.cart.items); ``` ## Tracking Actions in Progress Use `actionsInProgress()` to see which actions are currently being processed: ```dart // Returns an unmodifiable Set of actions currently running Set> inProgress = store.actionsInProgress(); // Check if any actions are running if (inProgress.isEmpty) { print('No actions in progress'); } else { for (var action in inProgress) { print('Running: ${action.runtimeType}'); } } // Get a copy of actions in progress Set> copy = store.copyActionsInProgress(); // Check if specific actions match bool matches = store.actionsInProgressEqualTo(expectedSet); ``` ## Dispatch and Reduce Counts Track how many actions have been dispatched and how many state reductions have occurred: ```dart // Total actions dispatched since store creation print('Dispatch count: ${store.dispatchCount}'); // Total state reductions performed print('Reduce count: ${store.reduceCount}'); ``` These counters are useful for: - Verifying actions dispatched during tests - Detecting unexpected dispatches - Performance monitoring ## Console Action Observer The built-in `ConsoleActionObserver` prints dispatched actions to the console with color formatting: ```dart var store = Store( initialState: AppState.initialState(), // Only enable in debug mode actionObservers: kReleaseMode ? null : [ConsoleActionObserver()], ); ``` Console output example: ``` I/flutter (15304): | Action MyAction I/flutter (15304): | Action LoadUserAction(user32) ``` Actions appear in yellow (default) or green (for `WaitAction` and `NavigateAction`). ### Customizing Action Output Override `toString()` in your actions to display additional information: ```dart class LoginAction extends AppAction { final String username; LoginAction(this.username); @override Future reduce() async { // ... } @override String toString() => 'LoginAction(username: $username)'; } ``` ### Custom Color Scheme Customize the color scheme by modifying the static `color` callback: ```dart ConsoleActionObserver.color = (action) { if (action is ErrorAction) return ConsoleActionObserver.red; if (action is NetworkAction) return ConsoleActionObserver.blue; return ConsoleActionObserver.yellow; }; ``` Available colors: `white`, `red`, `blue`, `yellow`, `green`, `grey`, `dark`. ## StateObserver for State Change Logging Create a `StateObserver` to log state changes: ```dart class DebugStateObserver implements StateObserver { @override void observe( ReduxAction action, AppState prevState, AppState newState, Object? error, int dispatchCount, ) { final changed = !identical(prevState, newState); print('--- Action #$dispatchCount: ${action.runtimeType} ---'); print('State changed: $changed'); if (changed) { // Log specific state changes if (prevState.user != newState.user) { print(' User changed: ${prevState.user} -> ${newState.user}'); } if (prevState.counter != newState.counter) { print(' Counter changed: ${prevState.counter} -> ${newState.counter}'); } } if (error != null) { print(' Error: $error'); } } } // Configure store var store = Store( initialState: AppState.initialState(), stateObservers: kDebugMode ? [DebugStateObserver()] : null, ); ``` ### Detecting State Changes Use `identical()` to check if state actually changed: ```dart bool stateChanged = !identical(prevState, newState); ``` This is efficient because AsyncRedux uses immutable state - if the reference is the same, no change occurred. ## Custom ActionObserver for Detailed Logging Create an `ActionObserver` for detailed dispatch tracking: ```dart class DetailedActionObserver implements ActionObserver { final Map _startTimes = {}; @override void observe( ReduxAction action, int dispatchCount, { required bool ini, }) { if (ini) { // Action started _startTimes[action] = DateTime.now(); print('[START #$dispatchCount] ${action.runtimeType}'); } else { // Action finished final startTime = _startTimes.remove(action); if (startTime != null) { final duration = DateTime.now().difference(startTime); print('[END #$dispatchCount] ${action.runtimeType} (${duration.inMilliseconds}ms)'); } else { print('[END #$dispatchCount] ${action.runtimeType}'); } } } } ``` ## Debugging Widget Rebuilds Use `ModelObserver` with `DefaultModelObserver` to track which widgets rebuild: ```dart var store = Store( initialState: AppState.initialState(), modelObserver: DefaultModelObserver(), ); ``` Output format: ``` Model D:1 R:1 = Rebuild:true, Connector:MyWidgetConnector, Model:MyViewModel{data}. Model D:2 R:2 = Rebuild:false, Connector:MyWidgetConnector, Model:MyViewModel{data}. ``` - `D`: Dispatch count - `R`: Rebuild count - `Rebuild`: Whether widget actually rebuilt - `Connector`: The StoreConnector type - `Model`: ViewModel with state summary Enable detailed output by passing `debug: this` to StoreConnector: ```dart StoreConnector( debug: this, // Enables connector name in output converter: (store) => MyViewModel.fromStore(store), builder: (context, vm) => MyWidget(vm), ) ``` ## Checking Action Status in Widgets Use context extensions to check action states: ```dart Widget build(BuildContext context) { // Check if action is currently running if (context.isWaiting(LoadDataAction)) { return CircularProgressIndicator(); } // Check if action failed if (context.isFailed(LoadDataAction)) { var exception = context.exceptionFor(LoadDataAction); return Text('Error: ${exception?.message}'); } return Text('Data: ${context.state.data}'); } ``` ## Waiting for Conditions in Tests Use store wait methods for test debugging: ```dart // Wait until state meets a condition await store.waitCondition((state) => state.isLoaded); // Wait for specific action types to complete await store.waitAllActionTypes([LoadUserAction, LoadSettingsAction]); // Wait for all actions to complete (empty list = wait for all) await store.waitAllActions([]); // Wait for action condition with access to actions in progress await store.waitActionCondition((actionsInProgress, triggerAction) { return actionsInProgress.isEmpty; }); ``` ## Complete Debug Setup Example ```dart void main() { final store = Store( initialState: AppState.initialState(), // Action logging (debug only) actionObservers: kDebugMode ? [ConsoleActionObserver(), DetailedActionObserver()] : null, // State change logging (debug only) stateObservers: kDebugMode ? [DebugStateObserver()] : null, // Widget rebuild tracking (debug only) modelObserver: kDebugMode ? DefaultModelObserver() : null, // Error observer (always enabled) errorObserver: MyErrorObserver(), ); // Debug print initial state if (kDebugMode) { print('Initial state: ${store.state}'); print('Dispatch count: ${store.dispatchCount}'); } runApp(StoreProvider( store: store, child: MyApp(), )); } ``` ## Debugging Tips 1. **Print state in actions**: Use `print(state)` in your reducer to see state at that moment 2. **Check initialState**: Access `action.initialState` to see state when action was dispatched (vs current `state`) 3. **Use action status**: Check `action.status.isCompletedOk` or `action.status.originalError` after dispatch 4. **Conditional logging**: Use `kDebugMode` from `package:flutter/foundation.dart` to disable in production 5. **Override toString**: Implement `toString()` on actions and state classes for better debug output ## References URLs from the documentation: - https://asyncredux.com/flutter/miscellaneous/logging - https://asyncredux.com/flutter/miscellaneous/metrics - https://asyncredux.com/flutter/miscellaneous/observing-rebuilds - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/wait-fail-succeed - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect ================================================ FILE: .claude/skills/asyncredux-dependency-injection/SKILL.md ================================================ --- name: asyncredux-dependency-injection description: Inject dependencies into actions using the environment, dependencies, and configuration pattern. Covers creating an Environment enum, a Dependencies class, passing them to the Store, accessing them from actions and widgets, and using dependency injection for testability. --- # Dependency Injection with Environment, Dependencies, and Configuration AsyncRedux provides dependency injection through three Store parameters: - **`environment`**: Specifies if the app is running in production, staging, development, testing, etc. Should be immutable and not change during app execution. Accessible from both actions and widgets. - **`dependencies`**: A container for injected services (repositories, APIs, etc.), created via a factory that receives the `Store`, so it can vary based on the environment. Usually not accessible from widgets. - **`configuration`**: For feature flags and other configuration values. Accessible from both actions and widgets. ## Step 1: Define the Environment Create an enum (or class) specifying the app's running context: ```dart enum Environment { production, staging, testing; bool get isProduction => this == Environment.production; bool get isStaging => this == Environment.staging; bool get isTesting => this == Environment.testing; } ``` ## Step 2: Define the Dependencies Create an abstract class with a factory that returns different implementations based on the environment: ```dart abstract class Dependencies { factory Dependencies(Store store) { if (store.environment == Environment.production) { return DependenciesProduction(); } else if (store.environment == Environment.staging) { return DependenciesStaging(); } else { return DependenciesTesting(); } } ApiClient get apiClient; AuthService get authService; int limit(int value); } class DependenciesProduction implements Dependencies { @override ApiClient get apiClient => RealApiClient(); @override AuthService get authService => FirebaseAuthService(); @override int limit(int value) => min(value, 5); } class DependenciesTesting implements Dependencies { @override ApiClient get apiClient => MockApiClient(); @override AuthService get authService => MockAuthService(); @override int limit(int value) => min(value, 1000); // Higher limit in tests } ``` ## Step 3: Define the Configuration (optional) ```dart class Config { bool isABtestingOn = false; bool showAdminConsole = false; } ``` ## Step 4: Pass All Three to the Store When creating the store, pass the environment, dependencies factory, and configuration factory: ```dart void main() { var store = Store( initialState: AppState.initialState(), environment: Environment.production, dependencies: (store) => Dependencies(store), configuration: (store) => Config(), ); runApp( StoreProvider( store: store, child: MyApp(), ), ); } ``` The `dependencies` and `configuration` parameters are factories that receive the `Store`, so they can read `store.environment` to vary their behavior. ## Step 5: Access from Actions via a Base Action Class Define a base action class with typed getters for `dependencies`, `environment`, and `configuration`: ```dart abstract class Action extends ReduxAction { Dependencies get dependencies => super.store.dependencies as Dependencies; Environment get environment => super.store.environment as Environment; Config get config => super.store.configuration as Config; } ``` Now use them in your actions: ```dart class FetchUserAction extends Action { final String userId; FetchUserAction(this.userId); @override Future reduce() async { final user = await dependencies.apiClient.fetchUser(userId); return state.copy(user: user); } } class IncrementAction extends Action { final int amount; IncrementAction({required this.amount}); @override AppState reduce() { int newState = state.counter + amount; int limitedState = dependencies.limit(newState); return state.copy(counter: limitedState); } } ``` ## Step 6: Access from Widgets via BuildContext Extension Create a `BuildContext` extension. The `environment` and `configuration` are available via `getEnvironment` and `getConfiguration`. Note: `dependencies` should usually NOT be accessed from widgets. ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); R select(R Function(AppState state) selector) => getSelect(selector); /// Access the environment from widgets (does not trigger rebuilds). Environment get environment => getEnvironment() as Environment; /// Access the configuration from widgets (does not trigger rebuilds). Config get config => getConfiguration() as Config; } ``` Use in widgets: ```dart class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { final env = context.environment; int counter = context.state; return Scaffold( appBar: AppBar(title: const Text('Dependency Injection Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Use the environment to change the UI. Text('Running in ${env}.', textAlign: TextAlign.center), Text('$counter', style: const TextStyle(fontSize: 30)), ], ), ), floatingActionButton: FloatingActionButton( onPressed: () => dispatch(IncrementAction(amount: 1)), child: const Icon(Icons.add), ), ); } } ``` ## Step 7 (if using StoreConnector): Access from VmFactory If you use `StoreConnector`, extend `VmFactory` with typed getters: ```dart abstract class AppFactory extends VmFactory { AppFactory([T? connector]) : super(connector); Dependencies get dependencies => store.dependencies as Dependencies; Environment get environment => store.environment as Environment; Config get config => store.configuration as Config; } ``` ## Testing with Different Environments The pattern makes testing straightforward by injecting test implementations: ```dart void main() { group('IncrementAction', () { test('increments counter with test dependencies', () async { var store = Store( initialState: AppState(counter: 0), environment: Environment.testing, dependencies: (store) => Dependencies(store), // Returns DependenciesTesting ); await store.dispatchAndWait(IncrementAction(amount: 5)); // DependenciesTesting has limit of 1000, so value is 5 expect(store.state.counter, 5); }); test('production dependencies limit counter', () async { var store = Store( initialState: AppState(counter: 3), environment: Environment.production, dependencies: (store) => Dependencies(store), // Returns DependenciesProduction ); await store.dispatchAndWait(IncrementAction(amount: 10)); // DependenciesProduction limits to 5 expect(store.state.counter, 5); }); }); } ``` ## Complete Working Example ```dart import 'dart:math'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; void main() { store = Store( initialState: 0, environment: Environment.production, dependencies: (store) => Dependencies(store), ); runApp(MyApp()); } enum Environment { production, staging, testing; bool get isProduction => this == Environment.production; bool get isStaging => this == Environment.staging; bool get isTesting => this == Environment.testing; } abstract class Dependencies { factory Dependencies(Store store) { if (store.environment == Environment.production) { return DependenciesProduction(); } else if (store.environment == Environment.staging) { return DependenciesStaging(); } else { return DependenciesTesting(); } } int limit(int value); } class DependenciesProduction implements Dependencies { @override int limit(int value) => min(value, 5); } class DependenciesStaging implements Dependencies { @override int limit(int value) => min(value, 25); } class DependenciesTesting implements Dependencies { @override int limit(int value) => min(value, 1000); } abstract class Action extends ReduxAction { Dependencies get dependencies => super.store.dependencies as Dependencies; } class IncrementAction extends Action { final int amount; IncrementAction({required this.amount}); @override int reduce() { int newState = state + amount; int limitedState = dependencies.limit(newState); return limitedState; } } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp(home: MyHomePage()), ); } } class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { final env = context.environment; int counter = context.state; return Scaffold( appBar: AppBar(title: const Text('Dependency Injection Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Running in ${env}.', textAlign: TextAlign.center), const Text( 'You have pushed the button this many times:\n' '(limited by the environment)', textAlign: TextAlign.center, ), Text('$counter', style: const TextStyle(fontSize: 30)), ], ), ), floatingActionButton: FloatingActionButton( onPressed: () => dispatch(IncrementAction(amount: 1)), child: const Icon(Icons.add), ), ); } } extension BuildContextExtension on BuildContext { int get state => getState(); int read() => getRead(); R select(R Function(int state) selector) => getSelect(selector); R? event(Evt Function(int state) selector) => getEvent(selector); Environment get environment => getEnvironment() as Environment; } ``` ## Key Benefits - **Separation of concerns**: `environment` identifies the running context, `dependencies` provides services, `configuration` holds feature flags - **Testability**: Swap implementations by changing the environment, without changing action code - **Type safety**: Typed getters in base action class provide compile-time checking - **Factory pattern**: The `dependencies` and `configuration` factories receive the `Store`, allowing them to vary based on `environment` - **Scoped dependencies**: Each store instance has its own environment/dependencies/configuration, preventing test contamination ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/miscellaneous/dependency-injection - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://github.com/marcglasberg/async_redux/blob/master/example/lib/main_dependency_injection.dart ================================================ FILE: .claude/skills/asyncredux-dispatching-actions/SKILL.md ================================================ --- name: asyncredux-dispatching-actions description: Dispatch actions using all available methods: `dispatch()`, `dispatchAndWait()`, `dispatchAll()`, `dispatchAndWaitAll()`, and `dispatchSync()`. Covers dispatching from widgets via context extensions and from within other actions. --- # Dispatching Actions The foundational principle of AsyncRedux: **the only way to change the application state is by dispatching actions.** You can dispatch from widgets (via context extensions) or from within other actions. ## Five Dispatch Methods ### 1. dispatch() The standard method that returns immediately. For synchronous actions, state updates before return; for async actions, the process begins and completes later. ```dart dispatch(MyAction()); ``` ### 2. dispatchAndWait() Returns a `Future` that completes when the action finishes and state changes, regardless of whether the action is sync or async. Returns an `ActionStatus` object. ```dart var status = await dispatchAndWait(MyAction()); if (status.isCompletedOk) { Navigator.pop(context); } ``` ### 3. dispatchAll() Dispatches multiple actions in parallel, returning the list of dispatched actions. ```dart dispatchAll([BuyAction('IBM'), SellAction('TSLA')]); ``` ### 4. dispatchAndWaitAll() Dispatches actions in parallel and waits for all to complete. ```dart await dispatchAndWaitAll([ BuyAction('IBM'), SellAction('TSLA'), ]); ``` ### 5. dispatchSync() Like `dispatch()` but throws a `StoreException` if the action is asynchronous. Use when synchronous execution is mandatory. ```dart dispatchSync(MyAction()); ``` ## Dispatching from Widgets All dispatch methods are available as `BuildContext` extensions: ```dart context.dispatch(Action()); context.dispatchAll([Action1(), Action2()]); await context.dispatchAndWait(Action()); await context.dispatchAndWaitAll([Action1(), Action2()]); context.dispatchSync(Action()); ``` Example button implementation: ```dart ElevatedButton( onPressed: () => context.dispatch(Increment()), child: Text('Increment'), ) ``` For async dispatch in callbacks: ```dart ElevatedButton( onPressed: () async { var status = await context.dispatchAndWait(SaveAction()); if (status.isCompletedOk) { Navigator.pop(context); } }, child: Text('Save'), ) ``` ## Dispatching from Within Actions All dispatch methods are available inside actions via the `ReduxAction` base class: ```dart class MyAction extends ReduxAction { Future reduce() async { // Dispatch another action and wait for it await dispatchAndWait(LoadDataAction()); // Dispatch without waiting dispatch(LogAction('Data loaded')); return state.copy(loaded: true); } } ``` ### Dispatching in before() and after() You can dispatch actions in the `before()` and `after()` lifecycle methods: ```dart class MyAction extends ReduxAction { Future reduce() async { String description = await fetchData(); return state.copy(description: description); } void before() => dispatch(BarrierAction(true)); void after() => dispatch(BarrierAction(false)); } ``` ## ActionStatus The `dispatchAndWait()` method returns an `ActionStatus` object with useful properties: ```dart var status = await dispatchAndWait(MyAction()); // Check completion state status.isCompleted; // Action finished executing status.isCompletedOk; // Completed without errors status.isCompletedFailed; // Completed with errors // Access error information status.originalError; // Error thrown by before/reduce status.wrappedError; // Error after wrapError() processing // Check method completion status.hasFinishedMethodBefore; status.hasFinishedMethodReduce; status.hasFinishedMethodAfter; ``` You can also access status directly from the action instance: ```dart var action = MyAction(); await dispatchAndWait(action); print(action.status.isCompletedOk); ``` ## The notify Parameter Dispatch methods accept an optional `notify` parameter (default `true`) that controls whether widgets rebuild on state changes: ```dart // Dispatch without triggering widget rebuilds dispatch(MyAction(), notify: false); ``` ## Summary Table | Method | Returns | Waits? | Use Case | |--------|---------|--------|----------| | `dispatch()` | `void` | No | Fire and forget | | `dispatchAndWait()` | `Future` | Yes | Need to know when done | | `dispatchAll()` | `List` | No | Multiple parallel actions | | `dispatchAndWaitAll()` | `Future` | Yes | Wait for all parallel actions | | `dispatchSync()` | `void` | N/A | Enforce sync execution | ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/basics/sync-actions - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://asyncredux.com/flutter/miscellaneous/advanced-waiting ================================================ FILE: .claude/skills/asyncredux-error-handling/SKILL.md ================================================ --- name: asyncredux-error-handling description: Implement comprehensive error handling for actions. Covers the `wrapError()` method for action-level error wrapping, GlobalWrapError for app-wide error transformation, ErrorObserver for logging/monitoring, and the error handling flow (before → reduce → after). --- # Error Handling in AsyncRedux AsyncRedux provides a comprehensive error handling system with multiple layers: action-level wrapping, global error transformation, and error observation for logging/monitoring. ## Error Flow and Action Lifecycle When errors occur during action execution: 1. If `before()` throws an error, the reducer doesn't execute and state remains unchanged 2. If `reduce()` throws an error, execution halts without state modification 3. The `after()` method **always** runs, even when errors occur (like a `finally` block) **Processing order:** `wrapError()` → `GlobalWrapError` → `ErrorObserver` ## Throwing Errors from Actions Actions can throw errors using `throw`. When an error is thrown, the reducer stops and state is not modified: ```dart class TransferMoney extends AppAction { final double amount; TransferMoney(this.amount); AppState? reduce() { if (amount == 0) { throw UserException('You cannot transfer zero money.'); } return state.copy(cash: state.cash - amount); } } ``` ## UserException for User-Facing Errors `UserException` is a built-in class for errors that users can understand and potentially fix (not code bugs): ```dart class SaveUser extends AppAction { final String name; SaveUser(this.name); Future reduce() async { if (name.length < 4) throw UserException('Name must have 4 letters.'); await saveUser(name); return null; } } ``` When a `UserException` is thrown, it's added to a special error queue in the store and can be displayed via `UserExceptionDialog`. ### Displaying UserExceptions Wrap your home page with `UserExceptionDialog` below both `StoreProvider` and `MaterialApp`: ```dart UserExceptionDialog( onShowUserExceptionDialog: (context, exception) => showDialog(...), child: MyHomePage(), ) ``` ## Action-Level Error Wrapping with wrapError() The `wrapError()` method acts as a catch block for entire actions. It receives the original error and stack trace, and must return: - A modified error (to transform the error) - `null` (to suppress/disable the error) - The unchanged error (to pass it through) ```dart class LogoutAction extends AppAction { @override Object? wrapError(Object error, StackTrace stackTrace) { return LogoutError("Logout failed", cause: error); } Future reduce() async { await authService.logout(); return state.copy(user: null); } } ``` ### Mixin Pattern for Reusable Error Handling Create mixins for consistent error transformation across multiple actions: ```dart mixin ShowUserException on AppAction { String getErrorMessage(); @override Object? wrapError(Object error, StackTrace stackTrace) { return UserException(getErrorMessage()).addCause(error); } } class LoadDataAction extends AppAction with ShowUserException { @override String getErrorMessage() => 'Failed to load data. Please try again.'; Future reduce() async { var data = await api.loadData(); return state.copy(data: data); } } ``` ### Suppressing Errors Return `null` from `wrapError()` to suppress errors without further propagation: ```dart @override Object? wrapError(Object error, StackTrace stackTrace) { if (error is CancelledException) { return null; // Silently ignore cancellation } return error; } ``` ## Global Error Handling with GlobalWrapError `GlobalWrapError` processes all action errors centrally. This is useful for transforming third-party library errors (like Firebase or platform exceptions): ```dart var store = Store( initialState: AppState.initialState(), globalWrapError: MyGlobalWrapError(), ); class MyGlobalWrapError extends GlobalWrapError { @override Object? wrap(Object error, StackTrace stackTrace, ReduxAction action) { // Transform platform exceptions to user-friendly messages if (error is PlatformException && error.code == "Error performing get") { return UserException('Check your internet connection').addCause(error); } // Transform Firebase errors if (error is FirebaseException) { return UserException('Service temporarily unavailable').addCause(error); } // Pass through all other errors unchanged return error; } } ``` Return `null` from `GlobalWrapError.wrap()` to suppress errors globally. ## Error Observation with ErrorObserver `ErrorObserver` receives all errors with context about the action and store. Use it for logging, monitoring, or analytics: ```dart var store = Store( initialState: AppState.initialState(), errorObserver: MyErrorObserver(), ); class MyErrorObserver implements ErrorObserver { @override bool observe( Object error, StackTrace stackTrace, ReduxAction action, Store store, ) { // Log the error print("Error during ${action.runtimeType}: $error"); // Send to crash reporting service crashlytics.recordError(error, stackTrace); // Return true to rethrow, false to swallow return true; } } ``` The `observe` method returns: - `true` to rethrow the error (default behavior) - `false` to swallow the error silently ## UserExceptionAction for Mid-Action Errors For showing error feedback while allowing the action to continue (without stopping execution): ```dart class ConvertAction extends AppAction { final String text; ConvertAction(this.text); Future reduce() async { var value = int.tryParse(text); if (value == null) { // Show error but continue action dispatch(UserExceptionAction('Please enter a valid number')); return null; // No state change } return state.copy(counter: value); } } ``` ## Checking Action Failure Status ### Using ActionStatus After dispatching with `dispatchAndWait()`, check the status: ```dart var status = await store.dispatchAndWait(SaveAction()); if (status.isCompletedOk) { Navigator.pop(context); } else if (status.isCompletedFailed) { var error = status.wrappedError; print('Save failed: $error'); } ``` **ActionStatus properties:** - `isCompletedOk`: Action finished without errors - `isCompletedFailed`: Action encountered errors - `originalError`: The error as thrown from `before` or `reduce` - `wrappedError`: The error after transformation by `wrapError()` ### Using isFailed in Widgets Check action failure state in the UI: ```dart Widget build(BuildContext context) { if (context.isFailed(LoadDataAction)) { var exception = context.exceptionFor(LoadDataAction); return Column( children: [ Text('Error: ${exception?.message}'), ElevatedButton( onPressed: () => context.dispatch(LoadDataAction()), child: Text('Retry'), ), ], ); } if (context.isWaiting(LoadDataAction)) { return CircularProgressIndicator(); } return DataWidget(data: context.state.data); } ``` The error is cleared automatically when the action is dispatched again. To manually clear the error: ```dart context.clearExceptionFor(LoadDataAction); ``` ## Testing Error Handling Test that actions fail with expected errors: ```dart test('action throws UserException for invalid input', () async { var store = Store(initialState: AppState.initialState()); var status = await store.dispatchAndWait(SaveUser('abc')); // too short expect(status.isCompletedFailed, isTrue); var error = status.wrappedError; expect(error, isA()); expect((error as UserException).msg, 'Name must have 4 letters.'); }); ``` Test multiple exceptions via the error queue: ```dart test('multiple actions accumulate errors', () async { var store = Store(initialState: AppState.initialState()); await store.dispatchAndWaitAll([ InvalidAction1(), InvalidAction2(), InvalidAction3(), ]); var errors = store.errors; expect(errors.length, 3); expect(errors[0].msg, 'First error message'); }); ``` ## Complete Store Setup with Error Handling ```dart var store = Store( initialState: AppState.initialState(), globalWrapError: MyGlobalWrapError(), errorObserver: MyErrorObserver(), actionObservers: [Log.printer(formatter: Log.verySimpleFormatter)], ); class MyGlobalWrapError extends GlobalWrapError { @override Object? wrap(Object error, StackTrace stackTrace, ReduxAction action) { if (error is SocketException) { return UserException('No internet connection').addCause(error); } return error; } } class MyErrorObserver implements ErrorObserver { @override bool observe(Object error, StackTrace stackTrace, ReduxAction action, Store store) { // Skip logging UserExceptions (they're expected) if (error is! UserException) { crashlytics.recordError(error, stackTrace); } return true; } } ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/advanced-actions/wrapping-the-reducer - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/testing/testing-user-exceptions - https://asyncredux.com/flutter/basics/wait-fail-succeed - https://asyncredux.com/flutter/miscellaneous/logging - https://asyncredux.com/flutter/advanced-actions/action-status ================================================ FILE: .claude/skills/asyncredux-events/SKILL.md ================================================ --- name: asyncredux-events description: Use the Event class to interact with Flutter's stateful widgets (TextField, ListView, etc.). Covers creating Event objects in state, consuming events with `context.event()`, scrolling lists, changing text fields, and the event lifecycle. --- # Events in AsyncRedux Events are **single-use notifications** used to trigger side effects in widgets. They're designed for controlling native Flutter widgets like `TextField` and `ListView` that manage their own state through controllers. ## When to Use Events Use events for: - **Controller actions**: Clearing text, changing text, scrolling lists, focusing inputs - **One-off UI actions**: Showing dialogs, snackbars, triggering animations - **Implicit state changes**: Navigation, any action that should happen exactly once Do NOT use events for: - Values that need to be read multiple times (use regular state instead) - Data that should be persisted (events should never be saved to local storage) ## Setup: Add context.event() Extension Add the `event` method to your BuildContext extension: ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); R select(R Function(AppState state) selector) => getSelect(selector); // Add this for events: R? event(Evt Function(AppState state) selector) => getEvent(selector); } ``` ## Creating Events ### Boolean Events For simple triggers that don't carry data: ```dart // Create an unspent event (will return true once) var clearTextEvt = Evt(); // Create a spent event (will return false) var clearTextEvt = Evt.spent(); ``` ### Typed Events For events that carry a value: ```dart // Create an unspent event with a value (will return value once, then null) var changeTextEvt = Evt("New text"); var scrollToIndexEvt = Evt(42); // Create a spent event (will return null) var changeTextEvt = Evt.spent(); ``` ## Declaring Events in State Initialize all events as **spent** in your initial state: ```dart class AppState { final Evt clearTextEvt; final Evt changeTextEvt; final Evt scrollToIndexEvt; AppState({ required this.clearTextEvt, required this.changeTextEvt, required this.scrollToIndexEvt, }); static AppState initialState() => AppState( clearTextEvt: Evt.spent(), changeTextEvt: Evt.spent(), scrollToIndexEvt: Evt.spent(), ); AppState copy({ Evt? clearTextEvt, Evt? changeTextEvt, Evt? scrollToIndexEvt, }) => AppState( clearTextEvt: clearTextEvt ?? this.clearTextEvt, changeTextEvt: changeTextEvt ?? this.changeTextEvt, scrollToIndexEvt: scrollToIndexEvt ?? this.scrollToIndexEvt, ); } ``` ## Dispatching Events from Actions Actions create **unspent** events and place them in state: ```dart // Boolean event - triggers clearing the text field class ClearTextAction extends AppAction { AppState reduce() => state.copy(clearTextEvt: Evt()); } // Typed event - changes the text field to a new value class ChangeTextAction extends AppAction { final String newText; ChangeTextAction(this.newText); AppState reduce() => state.copy(changeTextEvt: Evt(newText)); } // Typed event from async operation class FetchAndSetTextAction extends AppAction { Future reduce() async { String text = await api.fetchText(); return state.copy(changeTextEvt: Evt(text)); } } // Scroll to a specific index in a ListView class ScrollToItemAction extends AppAction { final int index; ScrollToItemAction(this.index); AppState reduce() => state.copy(scrollToIndexEvt: Evt(index)); } ``` ## Consuming Events in Widgets Use `context.event()` in the widget's build method. **The event is consumed (marked as spent) immediately when read.** ### TextField Example ```dart class MyTextField extends StatefulWidget { @override State createState() => _MyTextFieldState(); } class _MyTextFieldState extends State { final controller = TextEditingController(); @override void dispose() { controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Consume the clear event - returns true once, then false bool shouldClear = context.event((s) => s.clearTextEvt); if (shouldClear) { controller.clear(); } // Consume the change event - returns the value once, then null String? newText = context.event((s) => s.changeTextEvt); if (newText != null) { controller.text = newText; } return TextField(controller: controller); } } ``` ### ListView Scrolling Example ```dart class MyListView extends StatefulWidget { @override State createState() => _MyListViewState(); } class _MyListViewState extends State { final scrollController = ScrollController(); final itemHeight = 50.0; @override void dispose() { scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final items = context.select((s) => s.items); // Consume the scroll event int? scrollToIndex = context.event((s) => s.scrollToIndexEvt); if (scrollToIndex != null) { // Schedule the scroll after the frame is built WidgetsBinding.instance.addPostFrameCallback((_) { scrollController.animateTo( scrollToIndex * itemHeight, duration: Duration(milliseconds: 300), curve: Curves.easeOut, ); }); } return ListView.builder( controller: scrollController, itemCount: items.length, itemBuilder: (context, index) => SizedBox( height: itemHeight, child: Text(items[index]), ), ); } } ``` ## Event Lifecycle 1. **Created as spent**: Events start as `Evt.spent()` in initial state 2. **Dispatched as unspent**: Action creates `Evt()` or `Evt(value)` and puts it in state 3. **Widget rebuilds**: State change triggers widget rebuild 4. **Consumed once**: `context.event()` returns the value and marks the event as spent 5. **Returns null/false**: Subsequent reads return `null` (typed) or `false` (boolean) ## Important Rules ### Each Event Can Only Be Consumed by One Widget If multiple widgets need the same trigger, create separate events: ```dart class AppState { final Evt clearSearchEvt; // For search field final Evt clearCommentsEvt; // For comments field // ... } ``` ### Don't Use Events for Persistent Data Events are mutable and designed for one-time use. Never persist them to local storage. ### Event Equality Prevents Unnecessary Rebuilds Events have special equality methods that prevent unnecessary widget rebuilds when used correctly with the selector pattern. ## Advanced: Checking Event Status Without Consuming Use these methods to check an event's status without consuming it: ```dart // Check if an event has been consumed bool consumed = myEvent.isSpent; // Check if an event is ready to be consumed bool ready = myEvent.isNotSpent; // Get the underlying state without consuming var eventState = myEvent.state; ``` ## Advanced: Event.map() for Transformations Transform an event's value: ```dart // Map an event to a different type Evt nameEvt = Evt(42).map((value) => 'Item $value'); ``` ## Advanced: Consuming from Multiple Event Sources When you need to consume from multiple possible event sources: ```dart // Create an event that consumes from first non-spent source var combined = Event.from([event1, event2, event3]); // Or use the static method var value = Event.consumeFrom([event1, event2, event3]); ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/events - https://asyncredux.com/flutter/miscellaneous/advanced-events - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/connector/store-connector ================================================ FILE: .claude/skills/asyncredux-flutter-hooks/SKILL.md ================================================ --- name: asyncredux-flutter-hooks description: Integrate AsyncRedux with the flutter_hooks package. Covers adding flutter_hooks_async_redux, using the useSelector hook, and combining hooks with AsyncRedux state management. --- ## Overview The `flutter_hooks_async_redux` package provides a hooks-based API for accessing AsyncRedux state. If you prefer functional components with hooks over the widget-based `StoreConnector` pattern, this package lets you use hooks like `useSelector` and `useDispatch` to interact with the Redux store. ## Installation Add these dependencies to your `pubspec.yaml`: ```yaml dependencies: flutter_hooks: ^0.21.2 async_redux: ^24.2.2 flutter_hooks_async_redux: ^3.1.0 ``` Then run `flutter pub get`. ## Core Hooks ### useSelector Selects a part of the state and subscribes to updates. The widget rebuilds when the selected value changes: ```dart String username = useSelector((state) => state.username); ``` The `distinct` parameter (default `true`) controls whether the widget rebuilds only when the selected value changes. ### Creating a Custom useAppState Hook For convenience, define a custom hook that's pre-typed for your state: ```dart T useAppState(T Function(AppState state) converter, {bool distinct = true}) => useSelector(converter, distinct: distinct); ``` This simplifies state access throughout your app: ```dart // Instead of: String username = useSelector((state) => state.username); // Use: String username = useAppState((state) => state.username); ``` ### useDispatch Dispatches actions that may change the store state. Works with both sync and async actions: ```dart class MyWidget extends HookWidget { @override Widget build(BuildContext context) { var dispatch = useDispatch(); return ElevatedButton( onPressed: () => dispatch(IncrementAction()), child: Text('Increment'), ); } } ``` ### useDispatchAndWait Dispatches an action and returns a `Future` that resolves when the action completes: ```dart class MyWidget extends HookWidget { @override Widget build(BuildContext context) { var dispatchAndWait = useDispatchAndWait(); var dispatch = useDispatch(); Future handleSubmit() async { // Wait for first action to complete await dispatchAndWait(DoThisFirstAction()); // Then dispatch the second dispatch(DoThisSecondAction()); } return ElevatedButton( onPressed: handleSubmit, child: Text('Submit'), ); } } ``` You can also check the action status: ```dart var status = await dispatchAndWait(MyAction()); if (status.isCompletedOk) { // Action succeeded } ``` ### useDispatchSync Enforces synchronous action dispatch. Throws `StoreException` if you attempt to dispatch an async action: ```dart var dispatchSync = useDispatchSync(); dispatchSync(MySyncAction()); // OK dispatchSync(MyAsyncAction()); // Throws StoreException ``` ## Waiting and Error Hooks ### useIsWaiting Checks if an async action is currently being processed: ```dart class MyWidget extends HookWidget { @override Widget build(BuildContext context) { var dispatch = useDispatch(); var isLoading = useIsWaiting(LoadDataAction); return Column( children: [ if (isLoading) CircularProgressIndicator(), ElevatedButton( onPressed: () => dispatch(LoadDataAction()), child: Text('Load'), ), ], ); } } ``` You can check by action type, action instance, or multiple types: ```dart // By action type var isWaiting = useIsWaiting(MyAction); // By action instance var action = MyAction(); dispatch(action); var isWaiting = useIsWaiting(action); // Multiple types - true if ANY are in progress var isWaiting = useIsWaiting([BuyAction, SellAction]); ``` ### useIsFailed Checks if an action has failed: ```dart var isFailed = useIsFailed(MyAction); if (isFailed) { return Text('Something went wrong'); } ``` ### useExceptionFor Retrieves the `UserException` from a failed action: ```dart var exception = useExceptionFor(MyAction); if (exception != null) { return Text(exception.reason ?? 'Unknown error'); } ``` ### useClearExceptionFor Gets a function to clear the exception state for an action: ```dart var clearExceptionFor = useClearExceptionFor(); // Clear exception when user dismisses error ElevatedButton( onPressed: () => clearExceptionFor(MyAction), child: Text('Dismiss'), ) ``` ## Complete Example Here's a full example combining multiple hooks: ```dart class UserProfileWidget extends HookWidget { @override Widget build(BuildContext context) { // Select state var username = useAppState((state) => state.user.name); var email = useAppState((state) => state.user.email); // Dispatch hooks var dispatch = useDispatch(); var dispatchAndWait = useDispatchAndWait(); // Loading and error state var isLoading = useIsWaiting(UpdateProfileAction); var isFailed = useIsFailed(UpdateProfileAction); var exception = useExceptionFor(UpdateProfileAction); var clearException = useClearExceptionFor(); Future handleUpdate() async { var status = await dispatchAndWait(UpdateProfileAction()); if (status.isCompletedOk) { // Show success message } } return Column( children: [ Text('Username: $username'), Text('Email: $email'), if (isLoading) CircularProgressIndicator(), if (isFailed && exception != null) Row( children: [ Text(exception.reason ?? 'Update failed'), IconButton( icon: Icon(Icons.close), onPressed: () => clearException(UpdateProfileAction), ), ], ), ElevatedButton( onPressed: isLoading ? null : handleUpdate, child: Text('Update Profile'), ), ], ); } } ``` ## Hook Parameters Reference | Hook | Accepts | Returns | |------|---------|---------| | `useSelector` | Converter function | Selected value of type T | | `useDispatch` | None | Dispatch function | | `useDispatchAndWait` | None | Function returning `Future` | | `useDispatchSync` | None | Sync dispatch function | | `useIsWaiting` | Action type, instance, or list of types | `bool` | | `useIsFailed` | Action type, instance, or list of types | `bool` | | `useExceptionFor` | Action type, instance, or list of types | `UserException?` | | `useClearExceptionFor` | None | Clear function | ## Hooks vs StoreConnector Choose hooks when: - You prefer functional widget patterns - You're already using `flutter_hooks` in your project - You want concise state access without view-model boilerplate Choose `StoreConnector` when: - You want explicit separation between UI and state logic - You need the structured view-model pattern for testing - You're not using hooks elsewhere in your project Both approaches work well with AsyncRedux - pick the one that fits your team's preferences. ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/other-packages/using-flutter-hooks-package - https://pub.dev/packages/flutter_hooks_async_redux - https://github.com/marcglasberg/flutter_hooks_async_redux ================================================ FILE: .claude/skills/asyncredux-navigation/SKILL.md ================================================ --- name: asyncredux-navigation description: Handle navigation through actions using NavigateAction. Covers setting up the navigator key, dispatching NavigateAction for push/pop/replace, and testing navigation in isolation. --- # Navigation with NavigateAction AsyncRedux enables app navigation through action dispatching, making it easier to unit test navigation logic. This approach is optional and currently supports Navigator 1 only. ## Setup ### 1. Create and Register the Navigator Key Create a global navigator key and register it with NavigateAction during app initialization: ```dart import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; final navigatorKey = GlobalKey(); void main() async { NavigateAction.setNavigatorKey(navigatorKey); // ... rest of initialization runApp(MyApp()); } ``` ### 2. Configure MaterialApp Pass the same navigator key to your MaterialApp: ```dart class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( routes: { '/': (context) => HomePage(), '/details': (context) => DetailsPage(), '/settings': (context) => SettingsPage(), }, navigatorKey: navigatorKey, ), ); } } ``` ## Dispatching Navigation Actions ### Push Operations ```dart // Push a named route dispatch(NavigateAction.pushNamed('/details')); // Push a route with a Route object dispatch(NavigateAction.push( MaterialPageRoute(builder: (context) => DetailsPage()), )); // Push and replace current route (named) dispatch(NavigateAction.pushReplacementNamed('/newRoute')); // Push and replace current route (with Route object) dispatch(NavigateAction.pushReplacement( MaterialPageRoute(builder: (context) => NewPage()), )); // Pop current route and push a new named route dispatch(NavigateAction.popAndPushNamed('/otherRoute')); // Push named route and remove all routes until predicate is true dispatch(NavigateAction.pushNamedAndRemoveUntil( '/home', (route) => false, // Removes all routes )); // Push named route and remove all routes (convenience method) dispatch(NavigateAction.pushNamedAndRemoveAll('/home')); // Push route and remove until predicate dispatch(NavigateAction.pushAndRemoveUntil( MaterialPageRoute(builder: (context) => HomePage()), (route) => false, )); ``` ### Pop Operations ```dart // Pop the current route dispatch(NavigateAction.pop()); // Pop with a result value dispatch(NavigateAction.pop(result: 'some_value')); // Pop routes until predicate is true dispatch(NavigateAction.popUntil((route) => route.isFirst)); // Pop until reaching a specific named route dispatch(NavigateAction.popUntilRouteName('/home')); // Pop until reaching a specific route dispatch(NavigateAction.popUntilRoute(someRoute)); ``` ### Replace Operations ```dart // Replace a specific route with a new one dispatch(NavigateAction.replace( oldRoute: currentRoute, newRoute: MaterialPageRoute(builder: (context) => NewPage()), )); // Replace the route below the current one dispatch(NavigateAction.replaceRouteBelow( anchorRoute: currentRoute, newRoute: MaterialPageRoute(builder: (context) => NewPage()), )); ``` ### Remove Operations ```dart // Remove a specific route dispatch(NavigateAction.removeRoute(routeToRemove)); // Remove the route below a specific route dispatch(NavigateAction.removeRouteBelow(anchorRoute)); ``` ## Complete Example ```dart import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; final navigatorKey = GlobalKey(); void main() async { NavigateAction.setNavigatorKey(navigatorKey); store = Store(initialState: AppState()); runApp(MyApp()); } class AppState {} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( routes: { '/': (context) => HomePage(), '/details': (context) => DetailsPage(), }, navigatorKey: navigatorKey, ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Home')), body: Center( child: ElevatedButton( child: Text('Go to Details'), onPressed: () => context.dispatch(NavigateAction.pushNamed('/details')), ), ), ); } } class DetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Details')), body: Center( child: ElevatedButton( child: Text('Go Back'), onPressed: () => context.dispatch(NavigateAction.pop()), ), ), ); } } ``` ## Getting the Current Route Name Rather than storing the current route in your app state (which can create complications), access it directly: ```dart String routeName = NavigateAction.getCurrentNavigatorRouteName(context); ``` ## Navigation from Actions You can dispatch navigation actions from within other actions: ```dart class LoginAction extends ReduxAction { final String username; final String password; LoginAction({required this.username, required this.password}); @override Future reduce() async { final user = await api.login(username, password); // Navigate to home after successful login dispatch(NavigateAction.pushReplacementNamed('/home')); return state.copy(user: user); } } ``` ## Testing Navigation NavigateAction enables unit testing of navigation without widget or driver tests: ```dart test('login navigates to home on success', () async { final store = Store(initialState: AppState()); // Capture dispatched actions NavigateAction? navigateAction; store.actionObservers.add((action, ini, prevState, newState) { if (action is NavigateAction) { navigateAction = action; } }); await store.dispatchAndWait(LoginAction( username: 'test', password: 'password', )); // Assert navigation type expect(navigateAction!.type, NavigateType.pushReplacementNamed); // Assert route name expect( (navigateAction!.details as NavigatorDetails_PushReplacementNamed).routeName, '/home', ); }); ``` ### NavigateType Enum Values The `NavigateType` enum includes values for all navigation operations: - `push`, `pushNamed` - `pop` - `pushReplacement`, `pushReplacementNamed` - `popAndPushNamed` - `pushAndRemoveUntil`, `pushNamedAndRemoveUntil`, `pushNamedAndRemoveAll` - `popUntil`, `popUntilRouteName`, `popUntilRoute` - `replace`, `replaceRouteBelow` - `removeRoute`, `removeRouteBelow` ## Important Notes - Navigation via AsyncRedux is entirely optional - Currently supports Navigator 1 only - For modern navigation packages (like go_router), you'll need to create custom action implementations - Don't store the current route in your app state; use `getCurrentNavigatorRouteName()` instead ## References URLs from the documentation: - https://asyncredux.com/flutter/miscellaneous/navigation - https://asyncredux.com/flutter/testing/testing-navigation - https://github.com/marcglasberg/async_redux/blob/master/example/lib/main_navigate.dart - https://raw.githubusercontent.com/marcglasberg/async_redux/master/lib/src/navigate_action.dart ================================================ FILE: .claude/skills/asyncredux-nonreentrant-mixin/SKILL.md ================================================ --- name: asyncredux-nonreentrant-mixin description: Add the NonReentrant mixin to prevent an action from dispatching while already in progress. Covers preventing duplicate form submissions, avoiding race conditions, and protecting long-running operations. --- # NonReentrant Mixin The `NonReentrant` mixin prevents concurrent execution of the same action type. When an action instance is already running, new dispatches of that same action are silently aborted. ## Basic Usage Add the `NonReentrant` mixin to any action that should not run concurrently: ```dart class SaveAction extends AppAction with NonReentrant { Future reduce() async { await http.put('http://myapi.com/save', body: 'data'); return null; } } ``` With this mixin: - If the user clicks "Save" multiple times rapidly, only the first dispatch executes - Subsequent dispatches while the first is running are silently aborted - No duplicate API calls or race conditions occur ## How It Works The `NonReentrant` mixin overrides the `abortDispatch` method. When `abortDispatch()` returns `true`, the action's `before()`, `reduce()`, and `after()` methods will not run, and the state stays unchanged. By default, checks are based on the action's runtime type - multiple instances of the same action class cannot run simultaneously. ## Common Use Cases 1. **Preventing duplicate form submissions** - Stop users from accidentally submitting forms multiple times 2. **Protecting API calls** - Ensure save/update/delete operations don't fire concurrently 3. **Resource-intensive tasks** - Prevent expensive computations from running in parallel 4. **Avoiding race conditions** - Ensure sequential execution of operations that must not overlap ## Customization ### Allow Different Parameters to Run Concurrently Override `nonReentrantKeyParams()` to allow actions with different parameters to run in parallel: ```dart class SaveItemAction extends AppAction with NonReentrant { final String itemId; SaveItemAction(this.itemId); @override Object? nonReentrantKeyParams() => itemId; Future reduce() async { await saveItem(itemId); return null; } } ``` With this customization: - `SaveItemAction('A')` and `SaveItemAction('B')` can run concurrently - Two `SaveItemAction('A')` dispatches will still block each other ### Share Keys Across Different Action Types Override `computeNonReentrantKey()` to make different action classes block each other: ```dart class SaveUserAction extends AppAction with NonReentrant { final String orderId; SaveUserAction(this.orderId); @override Object? computeNonReentrantKey() => orderId; Future reduce() async { ... } } class DeleteUserAction extends AppAction with NonReentrant { final String orderId; DeleteUserAction(this.orderId); @override Object? computeNonReentrantKey() => orderId; Future reduce() async { ... } } ``` This prevents `SaveUserAction('123')` and `DeleteUserAction('123')` from running simultaneously - useful when different operations on the same resource must not overlap. ## Combining with Other Mixins You can combine `NonReentrant` with other compatible mixins: ```dart class LoadDataAction extends AppAction with CheckInternet, NonReentrant { Future reduce() async { final data = await fetchData(); return state.copy(data: data); } } ``` **Incompatible mixins:** `NonReentrant` cannot be combined with: - `Throttle` - `UnlimitedRetryCheckInternet` - Most optimistic update mixins (check the compatibility matrix) ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/aborting-the-dispatch - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/async-actions ================================================ FILE: .claude/skills/asyncredux-observers/SKILL.md ================================================ --- name: asyncredux-observers description: Set up observers for debugging and monitoring. Covers implementing actionObservers for dispatch logging, stateObserver for state change tracking, combining observers with globalWrapError, and using observers for analytics. --- # Setting Up Observers for Debugging and Monitoring AsyncRedux provides several observer types for monitoring actions, state changes, errors, and widget rebuilds. These observers are configured when creating the Store. ## Overview of Observer Types | Observer Type | Purpose | |--------------|---------| | `ActionObserver` | Monitor action dispatch (start and end) | | `StateObserver` | Monitor state changes after actions | | `ErrorObserver` | Monitor and handle action errors | | `ModelObserver` | Monitor widget rebuilds (for StoreConnector) | ## Store Configuration with Observers ```dart var store = Store( initialState: AppState.initialState(), actionObservers: [ConsoleActionObserver()], stateObservers: [MyStateObserver()], errorObserver: MyErrorObserver(), modelObserver: DefaultModelObserver(), ); ``` ## ActionObserver The `ActionObserver` monitors when actions are dispatched and when they complete. It triggers twice per action: at the start (INI) and at the end (END). ### ActionObserver Abstract Class ```dart abstract class ActionObserver { void observe( ReduxAction action, int dispatchCount, { required bool ini, }); } ``` ### Parameters - `action`: The dispatched action instance - `dispatchCount`: Sequential number of this dispatch - `ini`: `true` when action starts (INI phase), `false` when it ends (END phase) ### Observation Phases **INI Phase**: Action dispatch begins. The reducer hasn't modified state yet. Sync reducers may complete during this phase; async reducers start their async process. **END Phase**: The reducer has finished and returned the new state. State modifications are now observable. **Important**: Receiving an END observation does not guarantee all effects have finished. Async operations that were not awaited may continue running and dispatch additional actions later. ### Built-in ConsoleActionObserver AsyncRedux provides `ConsoleActionObserver` for development debugging: ```dart var store = Store( initialState: AppState.initialState(), actionObservers: kReleaseMode ? null : [ConsoleActionObserver()], ); ``` This prints actions in yellow to the console. Override `toString()` in your actions to display additional information: ```dart class LoadUserAction extends AppAction { final String username; LoadUserAction(this.username); @override Future reduce() async { // ... } @override String toString() => 'LoadUserAction(username: $username)'; } ``` ### Using Log.printer for Formatted Output ```dart var store = Store( initialState: AppState.initialState(), actionObservers: [Log.printer(formatter: Log.verySimpleFormatter)], ); ``` ### Custom ActionObserver Implementation ```dart class MyActionObserver implements ActionObserver { @override void observe( ReduxAction action, int dispatchCount, { required bool ini, }) { final phase = ini ? 'START' : 'END'; print('[$phase] Action #$dispatchCount: ${action.runtimeType}'); } } ``` ## StateObserver The `StateObserver` is notified of all state changes, allowing you to track, log, or record state history. ### StateObserver Abstract Class ```dart abstract class StateObserver { void observe( ReduxAction action, St prevState, St newState, Object? error, int dispatchCount, ); } ``` ### Parameters - `action`: The action that triggered the change - `prevState`: State before the reducer executed - `newState`: State returned by the reducer - `error`: Null if successful; contains the thrown error otherwise - `dispatchCount`: Sequential dispatch number ### Detecting State Changes Compare states using `identical()` to detect actual changes: ```dart bool stateChanged = !identical(prevState, newState); ``` ### Custom StateObserver for Logging ```dart class StateLogger implements StateObserver { @override void observe( ReduxAction action, AppState prevState, AppState newState, Object? error, int dispatchCount, ) { final changed = !identical(prevState, newState); print('Action #$dispatchCount: ${action.runtimeType}'); print(' State changed: $changed'); if (error != null) { print(' Error: $error'); } } } ``` ### StateObserver for Undo/Redo A common use case is recording state history for undo/redo functionality: ```dart class UndoRedoObserver implements StateObserver { final List _history = []; int _currentIndex = -1; final int maxHistorySize; UndoRedoObserver({this.maxHistorySize = 50}); bool get canUndo => _currentIndex > 0; bool get canRedo => _currentIndex < _history.length - 1; @override void observe( ReduxAction action, AppState prevState, AppState newState, Object? error, int dispatchCount, ) { // Skip undo/redo actions to avoid recording navigation if (action is UndoAction || action is RedoAction) return; // Skip if state didn't change if (identical(prevState, newState)) return; // Remove "future" states if we're navigating if (_currentIndex < _history.length - 1) { _history.removeRange(_currentIndex + 1, _history.length); } // Add new state _history.add(newState); _currentIndex = _history.length - 1; // Enforce max history size if (_history.length > maxHistorySize) { _history.removeAt(0); _currentIndex--; } } AppState? getPreviousState() { if (!canUndo) return null; _currentIndex--; return _history[_currentIndex]; } AppState? getNextState() { if (!canRedo) return null; _currentIndex++; return _history[_currentIndex]; } } ``` ## ErrorObserver The `ErrorObserver` monitors all errors thrown by actions and can suppress or allow them to propagate. ### Error Handling Flow The error handling order is: 1. `wrapError()` (action-level) 2. `GlobalWrapError` (app-level) 3. `ErrorObserver` (monitoring/logging) ### ErrorObserver Implementation ```dart class MyErrorObserver implements ErrorObserver { @override bool observe( Object error, StackTrace stackTrace, ReduxAction action, Store store, ) { // Log the error print('Error in ${action.runtimeType}: $error'); print(stackTrace); // Send to crash reporting service crashReporter.recordError(error, stackTrace, reason: action.runtimeType.toString()); // Return true to rethrow the error, false to suppress it return true; } } ``` ### Store Configuration with ErrorObserver ```dart var store = Store( initialState: AppState.initialState(), errorObserver: MyErrorObserver(), ); ``` ### Combining with GlobalWrapError Use `GlobalWrapError` to transform errors before they reach the `ErrorObserver`: ```dart var store = Store( initialState: AppState.initialState(), globalWrapError: MyGlobalWrapError(), errorObserver: MyErrorObserver(), ); class MyGlobalWrapError extends GlobalWrapError { @override Object? wrap(Object error, StackTrace stackTrace, ReduxAction action) { // Transform platform errors to user-friendly messages if (error is PlatformException) { return UserException('Check your internet connection').addCause(error); } return error; } } ``` ## ModelObserver The `ModelObserver` monitors widget rebuilds when using `StoreConnector`. This is useful for debugging rebuild behavior and ensuring efficient state updates. ### Setup ```dart var store = Store( initialState: AppState.initialState(), modelObserver: DefaultModelObserver(), ); ``` ### Console Output `DefaultModelObserver` prints rebuild information: ``` Model D:1 R:1 = Rebuild:true, Connector:MyWidgetConnector, Model:MyViewModel{B}. Model D:2 R:2 = Rebuild:false, Connector:MyWidgetConnector, Model:MyViewModel{B}. Model D:3 R:3 = Rebuild:true, Connector:MyWidgetConnector, Model:MyViewModel{C}. ``` - `D`: Dispatch count - `R`: Rebuild count - `Rebuild`: Whether the widget actually rebuilt - `Connector`: The StoreConnector type - `Model`: The ViewModel with current state ### Configuration for Better Output Pass `debug: this` to `StoreConnector` to enable connector type printing: ```dart class MyWidgetConnector extends StatelessWidget with StoreConnector { @override Widget build(BuildContext context) { return StoreConnector( debug: this, // Enable for ModelObserver output converter: (store) => MyViewModel.fromStore(store), builder: (context, vm) => MyWidget(vm), ); } } ``` Override `ViewModel.toString()` for custom diagnostic information. ## Using Observers for Analytics ### Metrics Observer Pattern Create a metrics observer that delegates to action-specific tracking methods: ```dart abstract class AppAction extends ReduxAction { /// Override in specific actions to track metrics void trackEvent(MetricsService metrics) {} } class MetricsObserver implements StateObserver { final MetricsService metrics; MetricsObserver(this.metrics); @override void observe( ReduxAction action, AppState prevState, AppState newState, Object? error, int dispatchCount, ) { if (action is AppAction) { action.trackEvent(metrics); } } } ``` Then override `trackEvent` in specific actions: ```dart class PurchaseAction extends AppAction { final Product product; PurchaseAction(this.product); @override Future reduce() async { await purchaseService.buy(product); return state.copy(purchases: state.purchases.add(product)); } @override void trackEvent(MetricsService metrics) { metrics.trackPurchase(productId: product.id, price: product.price); } } ``` ### Analytics ActionObserver Track all dispatched actions for analytics: ```dart class AnalyticsObserver implements ActionObserver { final AnalyticsService analytics; AnalyticsObserver(this.analytics); @override void observe( ReduxAction action, int dispatchCount, { required bool ini, }) { // Only track at start (ini) to avoid double-counting if (ini) { analytics.trackEvent( 'action_dispatched', parameters: {'action_type': action.runtimeType.toString()}, ); } } } ``` ## Complete Example: Store with All Observers ```dart // observers.dart class ConsoleStateObserver implements StateObserver { @override void observe( ReduxAction action, AppState prevState, AppState newState, Object? error, int dispatchCount, ) { final changed = !identical(prevState, newState); print('[$dispatchCount] ${action.runtimeType} - Changed: $changed'); if (error != null) print(' Error: $error'); } } class CrashReportingErrorObserver implements ErrorObserver { @override bool observe(Object error, StackTrace stackTrace, ReduxAction action, Store store) { // Don't report UserExceptions (they're expected) if (error is! UserException) { FirebaseCrashlytics.instance.recordError(error, stackTrace); } return true; // Rethrow the error } } // main.dart void main() { final store = Store( initialState: AppState.initialState(), // Only enable console observers in debug mode actionObservers: kDebugMode ? [ConsoleActionObserver()] : null, stateObservers: kDebugMode ? [ConsoleStateObserver()] : null, // Always enable error observer errorObserver: CrashReportingErrorObserver(), // Transform errors globally globalWrapError: MyGlobalWrapError(), ); runApp(StoreProvider( store: store, child: MyApp(), )); } ``` ## Multiple Observers You can use multiple observers of the same type: ```dart var store = Store( initialState: AppState.initialState(), actionObservers: [ ConsoleActionObserver(), AnalyticsObserver(analyticsService), PerformanceObserver(), ], stateObservers: [ StateLogger(), UndoRedoObserver(), MetricsObserver(metricsService), ], ); ``` All observers will be notified in the order they are listed. ## References URLs from the documentation: - https://asyncredux.com/flutter/miscellaneous/logging - https://asyncredux.com/flutter/miscellaneous/metrics - https://asyncredux.com/flutter/miscellaneous/observing-rebuilds - https://asyncredux.com/flutter/miscellaneous/undo-and-redo - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/basics/store ================================================ FILE: .claude/skills/asyncredux-optimistic-update-mixin/SKILL.md ================================================ --- name: asyncredux-optimistic-update-mixin description: Add the OptimisticUpdate mixin for instant UI feedback before server confirmation. Covers immediate state changes, automatic rollback on failure, and optionally notifying users of rollback. --- # Optimistic Update Mixins AsyncRedux provides three optimistic update mixins for different scenarios: | Mixin | Use Case | |-------|----------| | `OptimisticCommand` | One-time operations (create, delete, submit) with rollback | | `OptimisticSync` | Rapid toggling/interactions with coalescing | | `OptimisticSyncWithPush` | Real-time server push scenarios with revision tracking | ## OptimisticCommand Use for one-time server operations where immediate UI feedback matters: creating todos, deleting items, submitting forms, or processing payments. ### Basic Example Without optimistic updates (user waits for server): ```dart class SaveTodo extends AppAction { final Todo newTodo; SaveTodo(this.newTodo); Future reduce() async { await saveTodo(newTodo); var reloadedList = await loadTodoList(); return state.copy(todoList: reloadedList); } } ``` With OptimisticCommand (instant UI feedback): ```dart class SaveTodo extends AppAction with OptimisticCommand { final Todo newTodo; SaveTodo(this.newTodo); // Value to apply immediately to UI Object? optimisticValue() => newTodo; // Extract current value from state (for rollback comparison) Object? getValueFromState(AppState state) => state.todoList.getById(newTodo.id); // Apply value to state and return new state AppState applyValueToState(AppState state, Object? value) => state.copy(todoList: state.todoList.add(value as Todo)); // Send to server (retries if using Retry mixin) Future sendCommandToServer(Object? value) async => await saveTodo(newTodo); // Optional: reload from server on error Future reloadFromServer() async => await loadTodoList(); } ``` ### How Rollback Works If `sendCommandToServer` fails, the mixin automatically rolls back only if the current state still matches the optimistic value. This avoids undoing newer changes made while the request was in flight. Override these methods to customize rollback: ```dart // Determine whether to restore previous state bool shouldRollback() => true; // Specify exact state to restore AppState? rollbackState() => previousState; ``` ### Non-Reentrant by Default OptimisticCommand prevents concurrent execution of the same action. Use `nonReentrantKeyParams()` to allow parallel operations on different items: ```dart class SaveTodo extends AppAction with OptimisticCommand { final String itemId; SaveTodo(this.itemId); // Allow SaveTodo('A') and SaveTodo('B') to run simultaneously // but prevent two SaveTodo('A') from running together Object? nonReentrantKeyParams() => itemId; // ... rest of implementation } ``` Check if action is in progress in UI: ```dart if (context.isWaiting(SaveTodo)) { return CircularProgressIndicator(); } ``` ### Combining with Other Mixins - **With Retry**: Only `sendCommandToServer` retries; optimistic UI remains stable - **With CheckInternet**: No optimistic state applied when offline ## OptimisticSync Use for rapid user interactions (toggling likes, switches, sliders) where only the final value matters and intermediate states can be discarded. ### Toggle Example ```dart class ToggleLike extends AppAction with OptimisticSync { final String itemId; ToggleLike(this.itemId); // Allow concurrent operations on different items Object? optimisticSyncKeyParams() => itemId; // Value to apply optimistically (toggle current value) bool valueToApply() => !state.items[itemId].liked; // Apply optimistic change to state AppState applyOptimisticValueToState(AppState state, bool isLiked) => state.copy(items: state.items.setLiked(itemId, isLiked)); // Extract current value from state bool getValueFromState(AppState state) => state.items[itemId].liked; // Send to server Future sendValueToServer(Object? value) async => await api.setLiked(itemId, value); // Optional: Apply server response to state AppState? applyServerResponseToState(AppState state, Object serverResponse) => state.copy(items: state.items.setLiked(itemId, serverResponse as bool)); // Optional: Handle completion/errors Future onFinish(Object? error) async { if (error != null) { // Reload from server on failure var reloaded = await api.getItem(itemId); return state.copy(items: state.items.update(itemId, reloaded)); } return null; } } ``` ### How Coalescing Works Multiple rapid changes are merged into minimal server requests: 1. User taps like button 5 times quickly 2. UI updates instantly each time (toggle, toggle, toggle...) 3. Only **one** server request sends the final state 4. If state changes during in-flight request, a follow-up request sends the new final value ## OptimisticSyncWithPush Use when your app receives real-time server updates (WebSockets, Firebase) across multiple devices modifying shared data. ### Key Differences from OptimisticSync - Each local dispatch increments a `localRevision` counter - Server pushes do NOT increment `localRevision` - Follow-up logic compares revisions instead of just values - Stale pushes are automatically ignored ### Implementation ```dart class ToggleLike extends AppAction with OptimisticSyncWithPush { final String itemId; ToggleLike(this.itemId); Object? optimisticSyncKeyParams() => itemId; bool valueToApply() => !state.items[itemId].liked; AppState applyOptimisticValueToState(AppState state, bool isLiked) => state.copy(items: state.items.setLiked(itemId, isLiked)); bool getValueFromState(AppState state) => state.items[itemId].liked; // Read server revision from state int? getServerRevisionFromState(Object? key) => state.items[key as String].serverRevision; AppState? applyServerResponseToState(AppState state, Object serverResponse) => state.copy(items: state.items.setLiked(itemId, serverResponse as bool)); Future sendValueToServer(Object? value) async { // Get local revision BEFORE await int localRev = localRevision(); var response = await api.setLiked(itemId, value, localRev: localRev); // Record server's revision after response informServerRevision(response.serverRev); return response.liked; } } ``` ### ServerPush Mixin Handle incoming server pushes with automatic stale detection: ```dart class PushLikeUpdate extends AppAction with ServerPush { final String itemId; final bool liked; final int serverRev; PushLikeUpdate({ required this.itemId, required this.liked, required this.serverRev, }); // Link to corresponding OptimisticSyncWithPush action Type associatedAction() => ToggleLike; Object? optimisticSyncKeyParams() => itemId; int serverRevision() => serverRev; int? getServerRevisionFromState(Object? key) => state.items[key as String].serverRevision; AppState? applyServerPushToState(AppState state, Object? key, int serverRevision) => state.copy( items: state.items.update( key as String, (item) => item.copy(liked: liked, serverRevision: serverRevision), ), ); } ``` If incoming `serverRevision` ≤ current known revision, the push is automatically ignored. This prevents older server states from overwriting newer ones. ### Data Model for Revision Tracking Store server revisions in your data model: ```dart class Item { final bool liked; final int? serverRevision; Item({required this.liked, this.serverRevision}); Item copy({bool? liked, int? serverRevision}) => Item( liked: liked ?? this.liked, serverRevision: serverRevision ?? this.serverRevision, ); } ``` ## Notifying Users of Rollback To notify users when a rollback occurs, use `UserException` in your error handling: ```dart class SaveTodo extends AppAction with OptimisticCommand { // ... required methods ... Future sendCommandToServer(Object? value) async { try { return await saveTodo(newTodo); } catch (e) { // Throw UserException to show dialog after rollback throw UserException('Failed to save. Your change was reverted.').addCause(e); } } } ``` Or use `onFinish` with OptimisticSync: ```dart Future onFinish(Object? error) async { if (error != null) { // Dispatch a notification action dispatch(UserExceptionAction('Failed to update. Reverting...')); // Reload correct state from server var reloaded = await api.getItem(itemId); return state.copy(items: state.items.update(itemId, reloaded)); } return null; } ``` ## Choosing the Right Mixin | Scenario | Mixin | |----------|-------| | Create/delete/submit operations | `OptimisticCommand` | | Toggle switches, like buttons | `OptimisticSync` | | Sliders, rapid input changes | `OptimisticSync` | | Multi-device with real-time sync | `OptimisticSyncWithPush` + `ServerPush` | ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/optimistic-mixins - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/failed-actions ================================================ FILE: .claude/skills/asyncredux-persistence/SKILL.md ================================================ --- name: asyncredux-persistence description: Implement local state persistence using Persistor. Covers creating a custom Persistor class, implementing `readState()`, `persistDifference()`, `deleteState()`, using LocalPersist helper, throttling saves, and pausing/resuming persistence with app lifecycle. --- ## Overview AsyncRedux provides state persistence by passing a `persistor` object to the Store. This maintains app state on disk, enabling restoration between sessions. ## Store Initialization with Persistor At startup, read any existing state from disk, create default state if none exists, then initialize the store: ```dart var persistor = MyPersistor(); var initialState = await persistor.readState(); if (initialState == null) { initialState = AppState.initialState(); await persistor.saveInitialState(initialState); } var store = Store( initialState: initialState, persistor: persistor, ); ``` ## The Persistor Abstract Class The `Persistor` base class defines these methods: ```dart abstract class Persistor { /// Read persisted state, or return null if none exists Future readState(); /// Delete state from disk Future deleteState(); /// Save state changes. Provides both newState and lastPersistedState /// so you can compare them and save only the difference. Future persistDifference({ required St? lastPersistedState, required St newState }); /// Convenience method for initial saves Future saveInitialState(St state) => persistDifference(lastPersistedState: null, newState: state); /// Controls save frequency. Return null to disable throttling. Duration get throttle => const Duration(seconds: 2); } ``` ## Creating a Custom Persistor Extend the abstract class and implement the required methods: ```dart class MyPersistor extends Persistor { @override Future readState() async { // Read state from disk (e.g., from SharedPreferences, file, etc.) return null; } @override Future deleteState() async { // Delete state from disk } @override Future persistDifference({ required AppState? lastPersistedState, required AppState newState, }) async { // Save state to disk. // You can compare lastPersistedState with newState to save only changes. } @override Duration get throttle => const Duration(seconds: 2); } ``` ## Throttling The `throttle` getter controls how often state is saved. All changes within the throttle window are collected and saved in a single call. The default is 2 seconds. ```dart // Save at most every 5 seconds @override Duration get throttle => const Duration(seconds: 5); // Disable throttling (save immediately on every change) @override Duration? get throttle => null; ``` ## Forcing Immediate Save Dispatch `PersistAction()` to save immediately, bypassing the throttle: ```dart store.dispatch(PersistAction()); ``` ## Pausing and Resuming Persistence Control persistence with these store methods: ```dart store.pausePersistor(); // Pause saving store.persistAndPausePersistor(); // Save current state, then pause store.resumePersistor(); // Resume saving ``` ## App Lifecycle Integration Pause persistence when the app goes to background and resume when it becomes active. Create an `AppLifecycleManager` widget: ```dart class AppLifecycleManager extends StatefulWidget { final Widget child; const AppLifecycleManager({ Key? key, required this.child, }) : super(key: key); @override _AppLifecycleManagerState createState() => _AppLifecycleManagerState(); } class _AppLifecycleManagerState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState lifecycle) { store.dispatch(ProcessLifecycleChange_Action(lifecycle)); } @override Widget build(BuildContext context) => widget.child; } ``` Create an action to handle lifecycle changes: ```dart class ProcessLifecycleChange_Action extends ReduxAction { final AppLifecycleState lifecycle; ProcessLifecycleChange_Action(this.lifecycle); @override Future reduce() async { if (lifecycle == AppLifecycleState.resumed || lifecycle == AppLifecycleState.inactive) { store.resumePersistor(); } else if (lifecycle == AppLifecycleState.paused || lifecycle == AppLifecycleState.detached) { store.persistAndPausePersistor(); } else { throw AssertionError(lifecycle); } return null; } } ``` Wrap your app with the lifecycle manager: ```dart StoreProvider( store: store, child: AppLifecycleManager( child: MaterialApp( ... ), ), ) ``` ## LocalPersist Helper The `LocalPersist` class simplifies disk operations for Android/iOS. It works with simple object structures containing only primitives, lists, and maps. ```dart import 'package:async_redux/local_persist.dart'; // Create instance with a file name var persist = LocalPersist("myFile"); // Save data List simpleObjs = [ 'Hello', 42, true, [100, 200, {"name": "John"}], ]; await persist.save(simpleObjs); // Load data List loaded = await persist.load(); // Append data List moreObjs = ['more', 'data']; await persist.save(moreObjs, append: true); // File operations int length = await persist.length(); bool exists = await persist.exists(); await persist.delete(); // JSON operations for single objects await persist.saveJson(simpleObj); Object? simpleObj = await persist.loadJson(); ``` **Note:** `LocalPersist` only supports simple objects. For complex nested structures or custom classes, you need to implement serialization yourself (e.g., using JSON encoding with `toJson`/`fromJson` methods). ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/miscellaneous/persistence - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/miscellaneous/database-and-cloud - https://asyncredux.com/flutter/intro - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/about - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/miscellaneous/advanced-waiting ================================================ FILE: .claude/skills/asyncredux-provider-integration/SKILL.md ================================================ --- name: asyncredux-provider-integration description: Integrate AsyncRedux with the Provider package. Covers using provider_for_redux, the ReduxSelector widget, and choosing between StoreConnector and ReduxSelector approaches. --- # Provider Integration with AsyncRedux The `provider_for_redux` package bridges Provider and AsyncRedux, enabling you to use Provider's dependency injection with Redux state management. ## Installation Add the package to your `pubspec.yaml`: ```yaml dependencies: provider_for_redux: ^8.0.0 ``` ## Setting Up AsyncReduxProvider Replace `StoreProvider` with `AsyncReduxProvider` to expose three items to descendant widgets: - The Redux store (`Store`) - The application state (`AppState`) - The dispatch method (`Dispatch`) ```dart import 'package:provider_for_redux/provider_for_redux.dart'; void main() { final store = Store(initialState: AppState.initial()); runApp( AsyncReduxProvider.value( value: store, child: MaterialApp(home: MyHomePage()), ), ); } ``` ## Accessing State with Provider.of Access store components directly using standard Provider patterns: ```dart // Access state (rebuilds on state changes) final counter = Provider.of(context).counter; // Access dispatch (use listen: false for actions) Provider.of(context, listen: false)(IncrementAction()); // Access the store directly final store = Provider.of>(context, listen: false); ``` ## ReduxConsumer Widget `ReduxConsumer` provides store, state, and dispatch in a single builder, simplifying access: ```dart ReduxConsumer( builder: (context, store, state, dispatch, child) { return Column( children: [ Text('Counter: ${state.counter}'), Text('Description: ${state.description}'), ElevatedButton( onPressed: () => dispatch(IncrementAction()), child: Text('Increment'), ), ], ); }, ) ``` ## ReduxSelector Widget `ReduxSelector` prevents unnecessary rebuilds by selecting specific state portions. Only when selected values change does the widget rebuild. ### Using a List (Recommended) The simplest approach - explicitly list the properties that should trigger rebuilds: ```dart ReduxSelector( selector: (context, state) => [state.counter, state.description], builder: (context, store, state, dispatch, model, child) { return Column( children: [ Text('Counter: ${state.counter}'), Text('Description: ${state.description}'), ElevatedButton( onPressed: () => dispatch(IncrementAction()), child: Text('Increment'), ), ], ); }, ) ``` ### Using a Custom Model For structured data, use a Tuple or custom class: ```dart ReduxSelector>( selector: (context, state) => Tuple2(state.counter, state.description), builder: (context, store, state, dispatch, model, child) { return Column( children: [ Text('Counter: ${model.item1}'), Text('Description: ${model.item2}'), ElevatedButton( onPressed: () => dispatch(IncrementAction()), child: Text('Increment'), ), ], ); }, ) ``` ## Choosing Between StoreConnector and ReduxSelector Both approaches manage widget rebuilds during state changes, but serve different use cases: ### Use StoreConnector When: - You want to separate smart (store-aware) and dumb (presentational) widgets - You need view-models with explicit equality comparison - You're building reusable UI components that shouldn't know about Redux - You want to test UI widgets in isolation without a store ```dart class MyCounterConnector extends StatelessWidget { Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (context, vm) => MyCounter( counter: vm.counter, description: vm.description, onIncrement: vm.onIncrement, ), ); } } class ViewModel extends Vm { final int counter; final String description; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.description, required this.onIncrement, }) : super(equals: [counter, description]); } ``` ### Use ReduxSelector When: - You prefer minimal boilerplate - Direct store access within a single widget is acceptable - You want Provider-style dependency injection - You're integrating with existing Provider-based code ```dart ReduxSelector( selector: (context, state) => [state.counter, state.description], builder: (context, store, state, dispatch, model, child) { return MyCounter( counter: state.counter, description: state.description, onIncrement: () => dispatch(IncrementAction()), ); }, ) ``` ## Migration Strategy Both Provider and AsyncRedux connectors work simultaneously, enabling gradual migration: ```dart // Old code using StoreConnector continues to work class OldFeatureConnector extends StatelessWidget { Widget build(BuildContext context) { return StoreConnector( vm: () => OldFactory(this), builder: (context, vm) => OldFeatureWidget(vm: vm), ); } } // New code can use ReduxSelector class NewFeatureWidget extends StatelessWidget { Widget build(BuildContext context) { return ReduxSelector( selector: (context, state) => [state.newFeature], builder: (context, store, state, dispatch, model, child) { return NewFeatureContent(feature: state.newFeature); }, ); } } ``` This allows you to migrate incrementally without rewriting your entire application. ## Comparison Summary | Aspect | StoreConnector | ReduxSelector | |--------|---------------|---------------| | Boilerplate | More (ViewModel + Factory) | Less (inline selector) | | Separation | Smart/Dumb widget pattern | Single widget | | Testing | Easy to test UI in isolation | Requires store setup | | Provider compatibility | Native AsyncRedux | Full Provider integration | | Rebuild control | Via ViewModel equality | Via selector list | ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/other-packages/using-the-provider-package - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/connector/connector-pattern - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/miscellaneous/widget-selectors - https://pub.dev/packages/provider_for_redux - https://github.com/marcglasberg/provider_for_redux ================================================ FILE: .claude/skills/asyncredux-retry-mixin/SKILL.md ================================================ --- name: asyncredux-retry-mixin description: Add the Retry mixin for automatic retry with exponential backoff on action failure. Covers using Retry alone for limited retries, combining with UnlimitedRetries for infinite retries, and configuring retry behavior. --- # Retry Mixin The `Retry` mixin automatically retries failed actions using exponential backoff. When an error occurs in the `reduce()` method, the action is re-executed with progressively increasing delays between attempts. ## Basic Usage Add the `Retry` mixin to any action that should automatically retry on failure: ```dart class LoadDataAction extends AppAction with Retry { Future reduce() async { var data = await fetchDataFromServer(); return state.copy(data: data); } } ``` With this mixin: - If the action fails, it automatically retries up to 3 times (default) - Each retry waits longer than the previous (exponential backoff) - If all retries fail, the original error is thrown ## Configuration Parameters Override these getters to customize retry behavior: ```dart class LoadDataAction extends AppAction with Retry { // Delay before first retry (default: 350ms) int get initialDelay => 500; // Multiplier for delay growth (default: 2) int get multiplier => 2; // Maximum retry attempts (default: 3) int get maxRetries => 5; // Upper limit on delay to prevent excessive waits (default: 5000ms) int get maxDelay => 10000; Future reduce() async { var data = await fetchDataFromServer(); return state.copy(data: data); } } ``` | Parameter | Default | Purpose | |-----------|---------|---------| | `initialDelay` | 350 ms | Waiting period before first retry | | `multiplier` | 2 | Growth factor for delays between attempts | | `maxRetries` | 3 | Maximum retry count (total executions = maxRetries + 1) | | `maxDelay` | 5 sec | Upper limit on delay to prevent excessive waits | ### Retry Sequence Example With default settings (initialDelay=350ms, multiplier=2, maxRetries=3): 1. **Initial attempt** - Action runs, fails 2. **Wait 350ms** - First retry, fails 3. **Wait 700ms** - Second retry, fails 4. **Wait 1400ms** - Third retry, fails 5. **Error thrown** - All retries exhausted ## Timing Considerations Retry delays start **after** the reducer finishes, not from when the action was dispatched. If `reduce()` takes 1 second to fail and `initialDelay` is 350ms, the first retry starts 1.35 seconds after the action began. ## Tracking Retry Attempts Access the `attempts` getter within your action to know which attempt is currently running: ```dart class LoadDataAction extends AppAction with Retry { Future reduce() async { print('Attempt ${attempts + 1}'); // 0-indexed, so first attempt is 0 if (attempts > 0) { // Maybe try a different server on retries return state.copy(data: await fetchFromBackupServer()); } return state.copy(data: await fetchFromPrimaryServer()); } } ``` ## Unlimited Retries Combine `UnlimitedRetries` with `Retry` to retry indefinitely until the action succeeds: ```dart class CriticalSyncAction extends AppAction with Retry, UnlimitedRetries { Future reduce() async { await syncCriticalData(); return state.copy(syncComplete: true); } } ``` This is equivalent to setting `maxRetries` to `-1`. **Warning:** Using `await dispatchAndWait(action)` with `UnlimitedRetries` may hang indefinitely if the action continues failing. Use with caution and consider whether the action has a realistic chance of eventually succeeding. ## Important Behavior Notes ### Only reduce() Failures Trigger Retry The `Retry` mixin only retries when errors occur in the `reduce()` method. Failures in the `before()` method do **not** trigger retries - they fail immediately. ```dart class LoadDataAction extends AppAction with Retry { @override Future before() async { // Errors here will NOT trigger retry - action fails immediately await validatePermissions(); } Future reduce() async { // Only errors here trigger the retry mechanism return state.copy(data: await fetchData()); } } ``` ### Actions Become Asynchronous All actions using the `Retry` mixin become asynchronous, regardless of their original synchronous nature. This is because the retry mechanism needs to wait between attempts. ## Combining with NonReentrant (Best Practice) Most actions using `Retry` should also include the `NonReentrant` mixin to prevent multiple instances from running simultaneously: ```dart class SaveDataAction extends AppAction with NonReentrant, Retry { Future reduce() async { await saveToServer(); return state.copy(saved: true); } } ``` This prevents scenarios where: - User clicks "Save" multiple times - Multiple retry sequences run in parallel - Server receives duplicate or conflicting requests ## Combining with CheckInternet For network operations, combine `Retry` with `CheckInternet` to ensure connectivity before attempting the action: ```dart class FetchUserProfile extends AppAction with CheckInternet, Retry { Future reduce() async { var profile = await api.getUserProfile(); return state.copy(profile: profile); } } ``` The `CheckInternet` mixin runs first. If there's no connection, the action fails immediately without attempting retries. ## Common Use Cases ### API Calls with Transient Failures ```dart class FetchProductsAction extends AppAction with Retry { int get maxRetries => 3; int get initialDelay => 500; Future reduce() async { var products = await api.getProducts(); return state.copy(products: products); } } ``` ### Critical Sync Operations ```dart class SyncPendingChanges extends AppAction with Retry, UnlimitedRetries { int get initialDelay => 1000; int get maxDelay => 30000; // Cap at 30 seconds between retries Future reduce() async { await syncService.pushPendingChanges(); return state.copy(hasPendingChanges: false); } } ``` ### Payment Processing with Extended Retries ```dart class ProcessPaymentAction extends AppAction with NonReentrant, Retry { final double amount; ProcessPaymentAction(this.amount); int get maxRetries => 5; int get initialDelay => 1000; int get multiplier => 2; int get maxDelay => 10000; Future reduce() async { var result = await paymentGateway.process(amount); return state.copy(paymentStatus: result.status); } } ``` ## Mixin Compatibility **Compatible with:** - `CheckInternet` - `NoDialog` - `AbortWhenNoInternet` - `NonReentrant` - `Throttle` - `Debounce` **Can be combined with:** - `UnlimitedRetries` (enables infinite retries) ## Full Example with All Options ```dart class RobustApiAction extends AppAction with CheckInternet, NonReentrant, Retry { // Retry configuration int get initialDelay => 500; // 500ms before first retry int get multiplier => 2; // Double delay each time int get maxRetries => 4; // Try up to 5 times total int get maxDelay => 8000; // Never wait more than 8 seconds Future reduce() async { if (attempts > 0) { print('Retry attempt $attempts'); } var data = await api.fetchCriticalData(); return state.copy(data: data); } } ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/wrapping-the-reducer - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/basics/wait-fail-succeed ================================================ FILE: .claude/skills/asyncredux-selectors/SKILL.md ================================================ --- name: asyncredux-selectors description: Create and cache selectors for efficient state access. Covers writing selector functions, caching with `cache1` and `cache2`, the reselect pattern, and avoiding repeated computations in widgets. --- ## What Are Selectors? Selectors are functions that extract specific data from the Redux store state. They provide three key benefits: 1. **Compute derived data** - Transform or filter state into the format your widget needs 2. **Abstract state structure** - Components don't depend on how the state is organized 3. **Enable caching (memoization)** - Avoid unnecessary recalculations ## The Problem: Repeated Computations When displaying filtered or computed data, without selectors you might write: ```dart // INEFFICIENT - filters the entire list on every access state.users.where((user) => user.name.startsWith("A")).toList()[index].name; ``` This filtering operation runs every time the widget rebuilds, even when the data hasn't changed. ## Basic Selector Functions Create a selector function that performs the computation once: ```dart List selectUsersStartingWith(AppState state, String text) { return state.users.where((user) => user.name.startsWith(text)).toList(); } ``` ## Cached Selectors (Reselectors) For expensive computations, wrap your selector with a cache function. AsyncRedux provides built-in caching utilities. ### Basic Caching Example ```dart List selectUsersStartingWith(AppState state, {required String text}) => _selectUsersStartingWith(state)(text); static final _selectUsersStartingWith = cache1state_1param( (AppState state) => (String text) => state.users.where((user) => user.name.startsWith(text)).toList() ); ``` ### Optimized Caching (State Subset) For better performance, only depend on the specific state subset that matters: ```dart List selectUsersStartingWith(AppState state, {required String text}) => _selectUsersStartingWith(state.users)(text); static final _selectUsersStartingWith = cache1state_1param( (List users) => (String text) => users.where((user) => user.name.startsWith(text)).toList() ); ``` This version only recalculates when `state.users` changes, not when any part of `state` changes. ## Available Cache Functions AsyncRedux provides these caching functions: | Function | States | Parameters | Use Case | |----------|--------|------------|----------| | `cache1state` | 1 | 0 | Simple computed value from one state | | `cache1state_1param` | 1 | 1 | Filtered/computed value with one param | | `cache1state_2params` | 1 | 2 | Computation with two parameters | | `cache1state_0params_x` | 1 | Many | Variable number of parameters | | `cache2states` | 2 | 0 | Combines two state portions | | `cache2states_1param` | 2 | 1 | Combines two states with one param | | `cache2states_2params` | 2 | 2 | Combines two states with two params | | `cache2states_0params_x` | 2 | Many | Two states, variable params | | `cache3states` | 3 | 0 | Combines three state portions | | `cache3states_0params_x` | 3 | Many | Three states, variable params | The naming convention: `cache[N]state[s]_[M]param[s]` where N = number of states, M = number of parameters. ## Cache Characteristics - **Multiple cached results** - Maintains separate caches for different parameter combinations - **Weak-map storage** - Automatically discards cached data when states change or fall out of use - **Memory efficient** - Won't hold obsolete information ## Action Selectors Create selectors that actions can use via a dedicated class: ```dart class ActionSelect { final AppState state; ActionSelect(this.state); List get items => state.items; Item get selectedItem => state.selectedItem; Item? findById(int id) => state.items.firstWhereOrNull((item) => item.id == id); Item? searchByText(String text) => state.items.firstWhereOrNull((item) => item.text.contains(text)); int get selectedIndex => state.items.indexOf(state.selectedItem); } ``` Add a getter in your base action: ```dart abstract class AppAction extends ReduxAction { ActionSelect get select => ActionSelect(state); } ``` Usage in actions: ```dart class LoadItemAction extends AppAction { final int itemId; LoadItemAction(this.itemId); @override AppState? reduce() { var item = select.findById(itemId); if (item == null) return null; return state.copy(selectedItem: item); } } ``` ## Widget Selectors Create a `WidgetSelect` class for organized widget-level selectors: ```dart class WidgetSelect { final BuildContext context; WidgetSelect(this.context); List get items => context.select((st) => st.items); Item get selectedItem => context.select((st) => st.selectedItem); Item? findById(int id) => context.select((st) => st.items.firstWhereOrNull((item) => item.id == id)); Item? searchByText(String text) => context.select((st) => st.items.firstWhereOrNull((item) => item.text.contains(text))); int get selectedIndex => context.select((st) => st.items.indexOf(st.selectedItem)); } ``` Add to your BuildContext extension: ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); R select(R Function(AppState state) selector) => getSelect(selector); WidgetSelect get selector => WidgetSelect(this); } ``` Usage in widgets: ```dart Widget build(BuildContext context) { final item = context.selector.findById(42); return Text(item?.name ?? 'Not found'); } ``` ## Reusing Action Selectors in Widgets Widget selectors can leverage action selectors to avoid duplication: ```dart class WidgetSelect { final BuildContext context; WidgetSelect(this.context); Item? findById(int id) => context.select((st) => ActionSelect(st).findById(id)); Item? searchByText(String text) => context.select((st) => ActionSelect(st).searchByText(text)); } ``` ## Important Guidelines ### Avoid context.state Inside Selectors Never use `context.state` inside selector functions - this defeats selective rebuilding: ```dart // WRONG - rebuilds on any state change var items = context.select((state) => context.state.items.where(...)); // CORRECT - only rebuilds when items change var items = context.select((state) => state.items.where(...)); ``` ### Never Nest context.select Calls Nesting `context.select` causes errors: ```dart // WRONG - will cause errors var result = context.select((state) => context.select((s) => s.items).where(...) // Nested select! ); // CORRECT var items = context.select((state) => state.items); var result = items.where(...).toList(); ``` ## Comparison with External Reselect Package AsyncRedux's built-in caching differs from the external `reselect` package: | Feature | AsyncRedux | reselect | |---------|------------|----------| | Results per selector | Multiple (different params) | One only | | Memory on state change | Discards old cache | Retains indefinitely | ## Complete Example: Cached Filtered List ```dart // Selector with caching class UserSelectors { static List usersStartingWith(AppState state, String prefix) => _usersStartingWith(state.users)(prefix); static final _usersStartingWith = cache1state_1param( (List users) => (String prefix) => users.where((u) => u.name.startsWith(prefix)).toList() ); static List activeUsers(AppState state) => _activeUsers(state.users); static final _activeUsers = cache1state( (List users) => users.where((u) => u.isActive).toList() ); } // Usage in widget Widget build(BuildContext context) { var filtered = context.select( (state) => UserSelectors.usersStartingWith(state, 'A') ); return ListView.builder( itemCount: filtered.length, itemBuilder: (_, i) => Text(filtered[i].name), ); } ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/miscellaneous/cached-selectors - https://asyncredux.com/flutter/miscellaneous/widget-selectors - https://asyncredux.com/flutter/advanced-actions/action-selectors - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/connector/advanced-view-model ================================================ FILE: .claude/skills/asyncredux-setup/SKILL.md ================================================ --- name: asyncredux-setup description: Initialize, setup and configure AsyncRedux in a Flutter app. Use it whenever starting a new AsyncRedux project, or when the user requests. --- # AsyncRedux Setup ## Adding the Dependency Add AsyncRedux to your `pubspec.yaml`: ```yaml dependencies: async_redux: ^25.6.1 ``` Check [pub.dev](https://pub.dev/packages/async_redux) for the latest version. ## Creating the State Class Create an immutable `AppState` class (in file `app_state.dart`) with: * `copy()` method * `==` equals method * `hashCode` method * `initialState()` static factory If the app is new, and you don't have any state yet, create an empty `AppState`: ```dart @immutable class AppState { AppState(); static AppState initialState() => AppState(); AppState copy() => AppState(); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType; @override int get hashCode => 0; } ``` If there is existing state, create the `AppState` that incorporates that state. This is an example: ```dart @immutable class AppState { final String name; final int age; AppState({required this.name, required this.age}); static AppState initialState() => AppState(name: "", age: 0); AppState copy({String? name, int? age}) => AppState( name: name ?? this.name, age: age ?? this.age, ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && name == other.name && age == other.age; @override int get hashCode => Object.hash(name, age); } ``` All fields must be `final` (immutable). Add additional helper methods as needed: ```dart AppState withName(String name) => copy(name: name); AppState withAge(int age) => copy(age: age); ``` ## Creating the Store Find the place where you initialize your app (usually in `main.dart`), and import your `AppState` class (adapt the path as needed) and the AsyncRedux package: ```dart import 'app_state.dart'; import 'package:async_redux/async_redux.dart'; ``` Create the store with your initial state. Note that `PersistorDummy`, `GlobalWrapErrorDummy`, and `ConsoleActionObserver` are provided by AsyncRedux for basic setups. In the future these can be replaced with custom implementations as needed. ```dart late Store store; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Create the persistor, and try to read any previously saved state. var persistor = PersistorDummy(); AppState? initialState = await persistor.readState(); // If there is no saved state, create a new empty one and save it. if (initialState == null) { initialState = AppState.initialState(); await persistor.saveInitialState(initialState); } // Create the store. store = Store( initialState: initialState, persistor: persistor, globalWrapError: GlobalWrapErrorDummy(), actionObservers: [ConsoleActionObserver()], ); runApp(...); } ``` ## Wrapping with StoreProvider Wrap your app with `StoreProvider` to make the store accessible. Find the root of the widget tree of the app, and add it above `MaterialApp` (or `CupertinoApp`, adapting as needed). Note you will need to import the `store` too. ```dart import 'package:async_redux/async_redux.dart'; Widget build(context) { return StoreProvider( store: store, child: MaterialApp( ... ), ); } ``` ## Required Context Extensions You **must** add this extension to your file containing `AppState` (this is required for easier state access in widgets): ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ``` ## Required base action Create file `app_action.dart` with this abstract class extending `ReduxAction`: ```dart /// All actions extend this class. abstract class AppAction extends ReduxAction { ActionSelect get select => ActionSelect(state); } // Dedicated selector class to keep the base action clean. class ActionSelect { final AppState state; ActionSelect(this.state); } ``` ## Update CLAUDE.md Add the following information to the project's `CLAUDE.md`, so that all actions extend this base action: ```markdown ## Base Action All actions should extend `AppAction` instead of `ReduxAction`. There is a dedicated selector class called `ActionSelect` to keep the base action clean, by namespacing selectors under `select` and enabling IDE autocompletion. Example: ```dart class ProcessItem extends AppAction { final String itemId; ProcessItem(this.itemId); @override AppState reduce() { // IDE autocomplete shows: select.findById, select.completed, etc. final item = select.findById(itemId); // ... } } ``` ``` ================================================ FILE: .claude/skills/asyncredux-state-access/SKILL.md ================================================ --- name: asyncredux-state-access description: Access store state in widgets using `context.state`, `context.select()`, and `context.read()`. Covers when to use each method, setting up BuildContext extensions, and optimizing widget rebuilds with selective state access. --- ## BuildContext Extension Setup To access your application state in widgets, first define a `BuildContext` extension. Add this to your project (typically in a shared file that all widgets can import): ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ``` Replace `AppState` with your actual state class name. ## The Three State Access Methods ### context.state Grants access to the entire state object. All widgets that use `context.state` will automatically rebuild whenever the store state changes (any part of it). ```dart Widget build(BuildContext context) { return Text('Counter: ${context.state.counter}'); } ``` ### context.select() Retrieves only specific state portions. This is more efficient as it only rebuilds the widget when the selected part of the state changes. ```dart Widget build(BuildContext context) { var counter = context.select((state) => state.counter); return Text('Counter: $counter'); } ``` ### context.read() Retrieves state without triggering rebuilds. Use this in event handlers, `initState`, or anywhere you need to read state once without subscribing to changes. ```dart void _onButtonPressed() { var currentCount = context.read().counter; print('Current count is $currentCount'); } ``` ## When to Use Each Method | Method | Use In | Triggers Rebuilds? | Best For | |--------|--------|-------------------|----------| | `context.state` | `build` method | Yes, on any state change | Simple widgets or when you need many state properties | | `context.select()` | `build` method | Only when selected part changes | Performance-sensitive widgets | | `context.read()` | `initState`, event handlers, callbacks | No | One-time reads, button handlers | ## Accessing Multiple State Properties When you need several pieces of state, you have two options: **Option 1: Multiple select calls** ```dart Widget build(BuildContext context) { var name = context.select((state) => state.user.name); var email = context.select((state) => state.user.email); var itemCount = context.select((state) => state.items.length); // Widget rebuilds only if name, email, or itemCount changes return Text('$name ($email) - $itemCount items'); } ``` **Option 2: Dart records for combined selection** ```dart Widget build(BuildContext context) { var (name, email) = context.select((state) => (state.user.name, state.user.email)); return Text('$name ($email)'); } ``` ## Additional Context Methods for Action States Beyond state access, the context extension provides methods for tracking async action progress: ```dart Widget build(BuildContext context) { // Check if an action is currently running if (context.isWaiting(LoadDataAction)) { return CircularProgressIndicator(); } // Check if an action failed if (context.isFailed(LoadDataAction)) { var exception = context.exceptionFor(LoadDataAction); return Text('Error: ${exception?.message}'); } // Show the data return Text('Data: ${context.state.data}'); } ``` Available methods: - `context.isWaiting(ActionType)` - Returns true if the action is in progress - `context.isFailed(ActionType)` - Returns true if the action recently failed - `context.exceptionFor(ActionType)` - Gets the exception from a failed action - `context.clearExceptionFor(ActionType)` - Manually clears the stored exception ## Widget Selectors Pattern For complex selection logic, create a `WidgetSelect` class to organize reusable selectors: ```dart class WidgetSelect { final BuildContext context; WidgetSelect(this.context); // Getter shortcuts List get items => context.select((state) => state.items); User get currentUser => context.select((state) => state.user); // Custom finder methods Item? findById(int id) => context.select( (state) => state.items.firstWhereOrNull((item) => item.id == id) ); List searchByText(String text) => context.select( (state) => state.items.where((item) => item.name.contains(text)).toList() ); } ``` Add it to your BuildContext extension: ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); // ... other methods ... WidgetSelect get selector => WidgetSelect(this); } ``` Usage in widgets: ```dart Widget build(BuildContext context) { var user = context.selector.currentUser; var item = context.selector.findById(42); return Text('${user.name}: ${item?.name}'); } ``` ## Important Guidelines ### Avoid context.state inside selectors Never use `context.state` inside your selector functions. This defeats the purpose of selective rebuilding: ```dart // WRONG - rebuilds on any state change var items = context.select((state) => context.state.items.where(...)); // CORRECT - only rebuilds when items change var items = context.select((state) => state.items.where(...)); ``` ### Never nest context.select calls Nesting `context.select` causes errors. Always apply selection at the top level: ```dart // WRONG - will cause errors var result = context.select((state) => context.select((s) => s.items).where(...) // Nested select! ); // CORRECT var items = context.select((state) => state.items); var result = items.where(...); ``` ## Debugging Rebuilds To observe when widgets rebuild (useful for performance debugging), use a `ModelObserver`: ```dart var store = Store( initialState: AppState.initialState(), modelObserver: DefaultModelObserver(), ); ``` The `DefaultModelObserver` logs console output showing: - Whether a rebuild occurred - Which connector/widget triggered it - The view model state Example output: ``` Model D:1 R:1 = Rebuild:true, Connector:MyWidgetConnector, Model:MyViewModel{counter: 5} ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/miscellaneous/widget-selectors - https://asyncredux.com/flutter/miscellaneous/observing-rebuilds - https://asyncredux.com/flutter/miscellaneous/cached-selectors - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/advanced-actions/action-selectors - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/intro - https://asyncredux.com/flutter/basics/wait-fail-succeed ================================================ FILE: .claude/skills/asyncredux-state-design/SKILL.md ================================================ --- name: asyncredux-state-design description: Design immutable state classes following AsyncRedux best practices. Includes creating the AppState class with a `copy()` method, defining `initialState()`, composing nested state objects, and optionally using the fast_immutable_collections package for IList, ISet, and IMap. --- # AsyncRedux State Design ## Core Principle: Immutability State classes must be immutable—fields cannot be modified after creation. Instead of changing state directly, you create new instances. All fields should be marked `final`. ## Basic State Class Structure ```dart class AppState { final String name; final int age; AppState({required this.name, required this.age}); static AppState initialState() => AppState(name: "", age: 0); AppState copy({String? name, int? age}) => AppState( name: name ?? this.name, age: age ?? this.age, ); } ``` ### Key Components 1. **Final fields** - All state fields must be `final` 2. **`initialState()`** - Static factory method providing default values 3. **`copy()` method** - Creates modified instances without mutating original ## The copy() Method Pattern The `copy()` method accepts optional parameters for each field. If a parameter is null, it keeps the existing value: ```dart AppState copy({String? name, int? age}) => AppState( name: name ?? this.name, age: age ?? this.age, ); ``` You can also add convenience methods: ```dart AppState withName(String name) => copy(name: name); AppState withAge(int age) => copy(age: age); ``` ## Nested/Composite State For complex applications, compose multiple state classes within a single `AppState`: ```dart class AppState { final TodoList todoList; final User user; final Settings settings; AppState({ required this.todoList, required this.user, required this.settings, }); static AppState initialState() => AppState( todoList: TodoList.initialState(), user: User.initialState(), settings: Settings.initialState(), ); AppState copy({ TodoList? todoList, User? user, Settings? settings, }) => AppState( todoList: todoList ?? this.todoList, user: user ?? this.user, settings: settings ?? this.settings, ); } ``` Each nested class follows the same pattern: ```dart class User { final String name; final String email; User({required this.name, required this.email}); static User initialState() => User(name: "", email: ""); User copy({String? name, String? email}) => User( name: name ?? this.name, email: email ?? this.email, ); } ``` ## Updating Nested State in Actions ```dart class UpdateUserName extends ReduxAction { final String name; UpdateUserName(this.name); @override AppState reduce() { var newUser = state.user.copy(name: name); return state.copy(user: newUser); } } ``` ## Using fast_immutable_collections For lists, sets, and maps, use the `fast_immutable_collections` package (by the same author as AsyncRedux): ```yaml dependencies: fast_immutable_collections: ^10.0.0 ``` ### IList Example Use `Iterable` in constructors and copy methods, with `IList.orNull()` for conversion. This lets callers pass any iterable (List, Set, IList) without manual conversion: ```dart import 'package:fast_immutable_collections/fast_immutable_collections.dart'; class AppState { final IList todos; AppState({ Iterable? todos, }) : todos = IList.orNull(todos) ?? const IList.empty(); static AppState initialState() => AppState(); AppState copy({Iterable? todos}) => AppState(todos: IList.orNull(todos) ?? this.todos); // Convenience methods with business logic AppState addTodo(Todo todo) => copy(todos: todos.add(todo)); AppState removeTodo(Todo todo) => copy(todos: todos.remove(todo)); AppState toggleTodo(int index) => copy( todos: todos.replace(index, todos[index].copy(done: !todos[index].done)), ); } // Flexible usage: var state = AppState(); // Empty list var state = AppState(todos: [todo1, todo2]); // List works var state = AppState(todos: {todo1, todo2}); // Set works var state = AppState(todos: existingIList); // IList reused (no copy) ``` ### IMap Example Use `Map` in constructors and copy methods, with `IMap.orNull()` for conversion: ```dart class AppState { final IMap usersById; AppState({ Map? usersById, }) : usersById = IMap.orNull(usersById) ?? const IMap.empty(); static AppState initialState() => AppState(); AppState copy({Map? usersById}) => AppState(usersById: IMap.orNull(usersById) ?? this.usersById); AppState addUser(User user) => copy(usersById: usersById.add(user.id, user)); AppState removeUser(String id) => copy(usersById: usersById.remove(id)); } ``` ### ISet Example Use `Iterable` in constructors and copy methods, with `ISet.orNull()` for conversion: ```dart class AppState { final ISet selectedIds; AppState({ Iterable? selectedIds, }) : selectedIds = ISet.orNull(selectedIds) ?? const ISet.empty(); static AppState initialState() => AppState(); AppState copy({Iterable? selectedIds}) => AppState(selectedIds: ISet.orNull(selectedIds) ?? this.selectedIds); AppState toggleSelection(String id) => copy( selectedIds: selectedIds.contains(id) ? selectedIds.remove(id) : selectedIds.add(id), ); } ``` ## Events in State For one-time UI interactions (scrolling, text field changes), use `Evt`: ```dart class AppState { final Evt clearTextEvt; final Evt changeTextEvt; AppState({ required this.clearTextEvt, required this.changeTextEvt, }); static AppState initialState() => AppState( clearTextEvt: Evt.spent(), changeTextEvt: Evt.spent(), ); AppState copy({ Evt? clearTextEvt, Evt? changeTextEvt, }) => AppState( clearTextEvt: clearTextEvt ?? this.clearTextEvt, changeTextEvt: changeTextEvt ?? this.changeTextEvt, ); } ``` Events are initialized as "spent" and become active when replaced with new instances in actions. ## Business Logic in State Classes AsyncRedux recommends placing business logic in state classes, not in actions or widgets: ```dart class TodoList { final IList items; TodoList({required this.items}); // Business logic methods int get completedCount => items.where((t) => t.done).length; int get pendingCount => items.length - completedCount; double get completionRate => items.isEmpty ? 0 : completedCount / items.length; IList get completed => items.where((t) => t.done).toIList(); IList get pending => items.where((t) => !t.done).toIList(); TodoList addTodo(Todo todo) => TodoList(items: items.add(todo)); TodoList removeTodo(Todo todo) => TodoList(items: items.remove(todo)); } ``` Actions become simple orchestrators: ```dart class AddTodo extends ReduxAction { final Todo todo; AddTodo(this.todo); @override AppState reduce() => state.copy( todoList: state.todoList.addTodo(todo), ); } ``` ## State Access in Actions Actions access state through getters: - **`state`** - Current state (updates after each `await` in async actions) - **`initialState`** - State when the action was first dispatched (never changes) ```dart class MyAction extends ReduxAction { @override Future reduce() async { var originalValue = initialState.counter; // Preserved await someAsyncWork(); var currentValue = state.counter; // May have changed return state.copy(counter: currentValue + 1); } } ``` ## Testing Benefits Immutable state with pure methods makes unit testing straightforward: ```dart void main() { test('addTodo adds item to list', () { var state = AppState.initialState(); var todo = Todo(text: 'Test', done: false); var newState = state.addTodo(todo); expect(newState.todos.length, 1); expect(newState.todos.first.text, 'Test'); expect(state.todos.length, 0); // Original unchanged }); } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/state - https://asyncredux.com/flutter/basics/sync-actions - https://asyncredux.com/flutter/basics/changing-state-is-optional - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/events - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/miscellaneous/business-logic - https://asyncredux.com/flutter/miscellaneous/persistence - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/intro - https://asyncredux.com/flutter/about ================================================ FILE: .claude/skills/asyncredux-streams-timers/SKILL.md ================================================ --- name: asyncredux-streams-timers description: Manage Streams and Timers with AsyncRedux. Covers creating actions to start/stop streams, storing stream subscriptions in store props, dispatching actions from stream callbacks, and proper cleanup with disposeProps(). --- # AsyncRedux Streams and Timers ## Core Principles Two fundamental rules for working with streams and timers in AsyncRedux: 1. **Don't send streams or timers down to widgets.** Don't declare, subscribe, or unsubscribe to them inside widgets. 2. **Don't put streams or timers in the Redux store state.** They produce state changes, but they are not state themselves. Instead, store streams and timers in the store's **props** - a key-value container that can hold any object type. ## Store Props API AsyncRedux provides methods for managing props in both `Store` and `ReduxAction`: ### `setProp(key, value)` Stores an object (timer, stream subscription, etc.) in the store's props: ```dart setProp('myTimer', Timer.periodic(Duration(seconds: 1), callback)); setProp('priceStream', priceStream.listen(onData)); ``` ### `prop(key)` Retrieves a property from the store: ```dart var timer = prop('myTimer'); var subscription = prop('priceStream'); ``` ### `disposeProp(key)` Disposes a single property by its key. Automatically cancels/closes timers, futures, and stream subscriptions: ```dart disposeProp('myTimer'); // Cancels the timer and removes from props ``` ### `disposeProps([predicate])` Disposes multiple properties. Without a predicate, disposes all Timer, Future, and Stream-related props: ```dart // Dispose all timers, futures, stream subscriptions disposeProps(); // Dispose only timers disposeProps(({Object? key, Object? value}) => value is Timer); // Dispose props with specific keys disposeProps(({Object? key, Object? value}) => key.toString().startsWith('temp_')); ``` ## Timer Pattern ### Starting a Timer Create an action that sets up a `Timer.periodic` and stores it in props: ```dart class StartPollingAction extends ReduxAction { @override AppState? reduce() { // Store the timer in props setProp('pollingTimer', Timer.periodic( Duration(seconds: 5), (timer) => dispatch(FetchDataAction()), )); return null; // No state change from this action } } ``` ### Stopping a Timer Create an action to dispose the timer: ```dart class StopPollingAction extends ReduxAction { @override AppState? reduce() { disposeProp('pollingTimer'); return null; } } ``` ### Timer with Tick Count Access the timer's tick count in callbacks: ```dart class StartTimerAction extends ReduxAction { @override AppState? reduce() { setProp('myTimer', Timer.periodic( Duration(seconds: 1), (timer) => dispatch(UpdateTickAction(timer.tick)), )); return null; } } class UpdateTickAction extends ReduxAction { final int tick; UpdateTickAction(this.tick); @override AppState? reduce() => state.copy(tickCount: tick); } ``` ## Stream Pattern ### Subscribing to a Stream Create an action that subscribes to a stream and stores the subscription: ```dart class StartListeningAction extends ReduxAction { @override AppState? reduce() { final subscription = myDataStream.listen( (data) => dispatch(DataReceivedAction(data)), onError: (error) => dispatch(StreamErrorAction(error)), ); setProp('dataSubscription', subscription); return null; } } ``` ### Unsubscribing from a Stream ```dart class StopListeningAction extends ReduxAction { @override AppState? reduce() { disposeProp('dataSubscription'); return null; } } ``` ### Handling Stream Data The stream callback dispatches an action with the data, which updates the state: ```dart class DataReceivedAction extends ReduxAction { final MyData data; DataReceivedAction(this.data); @override AppState? reduce() => state.copy(latestData: data); } ``` ## Lifecycle Management ### Screen-Specific Streams/Timers Use `StoreConnector`'s `onInit` and `onDispose` callbacks: ```dart class PriceScreen extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => _Factory(), onInit: _onInit, onDispose: _onDispose, builder: (context, vm) => PriceWidget(price: vm.price), ); } void _onInit(Store store) { store.dispatch(StartPriceStreamAction()); } void _onDispose(Store store) { store.dispatch(StopPriceStreamAction()); } } ``` ### App-Wide Streams/Timers Start after store creation, stop when app closes: ```dart void main() { final store = Store(initialState: AppState.initialState()); // Start app-wide streams/timers store.dispatch(StartGlobalPollingAction()); runApp(StoreProvider( store: store, child: MyApp(), )); } // In your app's dispose logic store.dispatch(StopGlobalPollingAction()); store.disposeProps(); // Clean up all remaining props store.shutdown(); ``` ### Single Action That Toggles Combine start/stop in one action: ```dart class TogglePollingAction extends ReduxAction { final bool start; TogglePollingAction(this.start); @override AppState? reduce() { if (start) { setProp('polling', Timer.periodic( Duration(seconds: 5), (_) => dispatch(RefreshDataAction()), )); } else { disposeProp('polling'); } return null; } } ``` ## Complete Example: Real-Time Price Updates ```dart // State class AppState { final double price; final bool isStreaming; AppState({required this.price, required this.isStreaming}); static AppState initialState() => AppState(price: 0.0, isStreaming: false); AppState copy({double? price, bool? isStreaming}) => AppState( price: price ?? this.price, isStreaming: isStreaming ?? this.isStreaming, ); } // Start streaming prices class StartPriceStreamAction extends ReduxAction { @override AppState? reduce() { // Don't start if already streaming if (state.isStreaming) return null; final subscription = priceService.priceStream.listen( (price) => dispatch(UpdatePriceAction(price)), onError: (e) => dispatch(PriceStreamErrorAction(e)), ); setProp('priceSubscription', subscription); return state.copy(isStreaming: true); } } // Stop streaming prices class StopPriceStreamAction extends ReduxAction { @override AppState? reduce() { if (!state.isStreaming) return null; disposeProp('priceSubscription'); return state.copy(isStreaming: false); } } // Handle price updates class UpdatePriceAction extends ReduxAction { final double price; UpdatePriceAction(this.price); @override AppState? reduce() => state.copy(price: price); } // Handle stream errors class PriceStreamErrorAction extends ReduxAction { final Object error; PriceStreamErrorAction(this.error); @override AppState? reduce() { // Stop streaming on error disposeProp('priceSubscription'); return state.copy(isStreaming: false); } } ``` ## Testing onInit/onDispose Use `ConnectorTester` to test lifecycle callbacks without full widget tests: ```dart test('starts and stops polling on screen lifecycle', () async { var store = Store(initialState: AppState.initialState()); var connectorTester = store.getConnectorTester(PriceScreen()); // Simulate screen entering view connectorTester.runOnInit(); var startAction = await store.waitAnyActionTypeFinishes([StartPriceStreamAction]); expect(store.state.isStreaming, true); // Simulate screen leaving view connectorTester.runOnDispose(); var stopAction = await store.waitAnyActionTypeFinishes([StopPriceStreamAction]); expect(store.state.isStreaming, false); }); ``` ## Cleanup on Store Shutdown Call `disposeProps()` before shutting down the store to clean up all remaining timers and stream subscriptions: ```dart // Clean up all Timer, Future, and Stream-related props store.disposeProps(); // Shut down the store store.shutdown(); ``` The `disposeProps()` method automatically: - Cancels `Timer` objects - Cancels `StreamSubscription` objects - Closes `StreamController` and `StreamSink` objects - Ignores `Future` objects (to prevent unhandled errors) Regular (non-disposable) props are kept unless you provide a predicate that matches them. ## References URLs from the documentation: - https://asyncredux.com/flutter/miscellaneous/streams-and-timers - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/testing/testing-oninit-ondispose - https://asyncredux.com/flutter/miscellaneous/dependency-injection ================================================ FILE: .claude/skills/asyncredux-sync-actions/SKILL.md ================================================ --- name: asyncredux-sync-actions description: Creates AsyncRedux (Flutter) synchronous actions that update state immediately by implementing reduce() to return a new state. --- # AsyncRedux Sync Actions ## Basic Sync Action Structure A synchronous action returns `AppState?` from its `reduce()` method. The action completes immediately and state updates right away. ```dart class Increment extends ReduxAction { @override AppState? reduce() => state.copy(counter: state.counter + 1); } ``` ## Key Components ### Extending ReduxAction Every action extends `ReduxAction`: ```dart class MyAction extends ReduxAction { @override AppState? reduce() { // Return new state } } ``` ### The `state` Getter Inside `reduce()`, access current state via the `state` getter: ```dart class ToggleFlag extends ReduxAction { @override AppState? reduce() => state.copy(flag: !state.flag); } ``` ### Passing Parameters via Constructor Pass data to actions through constructor fields: ```dart class SetName extends ReduxAction { final String name; SetName(this.name); @override AppState? reduce() => state.copy(name: name); } class IncrementBy extends ReduxAction { final int amount; IncrementBy({required this.amount}); @override AppState? reduce() => state.copy(counter: state.counter + amount); } ``` ### Modifying Nested State For nested state objects, create the new nested object first: ```dart class UpdateUserName extends ReduxAction { final String name; UpdateUserName(this.name); @override AppState? reduce() { var newUser = state.user.copy(name: name); return state.copy(user: newUser); } } ``` ## Dispatching Sync Actions ### From Widgets Use context extensions: ```dart // Fire and forget context.dispatch(Increment()); // With parameters context.dispatch(SetName('Alice')); context.dispatch(IncrementBy(amount: 5)); ``` ### Immediate State Update Sync actions update state immediately: ```dart print(store.state.counter); // 2 store.dispatch(IncrementBy(amount: 3)); print(store.state.counter); // 5 ``` ### Guaranteed Sync with dispatchSync() The `dispatchSync()` throws `StoreException` if the action is async. Otherwise, it behaves exactly like `dispatch()`. Use `dispatchSync()` only in the rare cases when you must ensure the action is synchronous because you need the state to be applied right after the dispatch returns. ```dart context.dispatchSync(Increment()); ``` ### From Other Actions Actions can dispatch other actions: ```dart class ResetAndIncrement extends ReduxAction { @override AppState? reduce() { dispatch(Reset()); dispatch(Increment()); return null; // This action itself doesn't change state } } ``` ## Returning Null (No State Change) Return `null` when you don't need to change state: ```dart class LogCurrentState extends ReduxAction { @override AppState? reduce() { print('Current counter: ${state.counter}'); return null; // No state change } } ``` Conditional state changes: ```dart class IncrementIfPositive extends ReduxAction { final int amount; IncrementIfPositive(this.amount); @override AppState? reduce() { if (amount <= 0) return null; return state.copy(counter: state.counter + amount); } } ``` ## Action Simplification with Base Class Create a base action class to reduce boilerplate: ```dart // Define once abstract class AppAction extends ReduxAction {} // Use everywhere class Increment extends AppAction { @override AppState? reduce() => state.copy(counter: state.counter + 1); } class SetName extends AppAction { final String name; SetName(this.name); @override AppState? reduce() => state.copy(name: name); } ``` You can add shared functionality to your base class: ```dart abstract class AppAction extends ReduxAction { // Shortcuts to state parts User get user => state.user; Settings get settings => state.settings; } class UpdateEmail extends AppAction { final String email; UpdateEmail(this.email); @override AppState? reduce() => state.copy( user: user.copy(email: email), // Uses shortcut ); } ``` ## Return Type Warning The `reduce()` method signature is `FutureOr`. For sync actions, always return `AppState?` directly: ```dart // CORRECT - Sync action AppState? reduce() => state.copy(counter: state.counter + 1); // WRONG - Don't return FutureOr directly FutureOr reduce() => state.copy(counter: state.counter + 1); ``` If you return `FutureOr` directly, AsyncRedux cannot determine if the action is sync or async and will throw a `StoreException`. ## Complete Example ```dart // State class AppState { final int counter; final String name; AppState({required this.counter, required this.name}); static AppState initialState() => AppState(counter: 0, name: ''); AppState copy({int? counter, String? name}) => AppState( counter: counter ?? this.counter, name: name ?? this.name, ); } // Base action abstract class AppAction extends ReduxAction {} // Sync actions class Increment extends AppAction { @override AppState? reduce() => state.copy(counter: state.counter + 1); } class Decrement extends AppAction { @override AppState? reduce() => state.copy(counter: state.counter - 1); } class IncrementBy extends AppAction { final int amount; IncrementBy(this.amount); @override AppState? reduce() => state.copy(counter: state.counter + amount); } class SetName extends AppAction { final String name; SetName(this.name); @override AppState? reduce() => state.copy(name: name); } class Reset extends AppAction { @override AppState? reduce() => AppState.initialState(); } // Usage in widget ElevatedButton( onPressed: () => context.dispatch(IncrementBy(5)), child: Text('Add 5'), ) ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/sync-actions - https://asyncredux.com/flutter/basics/actions-and-reducers - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/action-simplification - https://asyncredux.com/flutter/basics/changing-state-is-optional ================================================ FILE: .claude/skills/asyncredux-testing-basics/SKILL.md ================================================ --- name: asyncredux-testing-basics description: Write unit tests for AsyncRedux actions using the Store directly. Covers creating test stores with initial state, using `dispatchAndWait()`, checking state after actions, verifying action errors via ActionStatus, and testing async actions. --- # Testing AsyncRedux Actions The recommended approach for testing AsyncRedux is to use the `Store` directly rather than the deprecated `StoreTester`. This provides a clean, straightforward testing pattern. ## Creating a Test Store Create a store with test-specific initial state: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:async_redux/async_redux.dart'; void main() { test('should increment counter', () async { // Create store with initial state var store = Store( initialState: AppState(counter: 0, name: ''), ); // Test your actions here }); } ``` For test isolation, create a fresh store in each test: ```dart void main() { late Store store; setUp(() { store = Store( initialState: AppState.initialState(), ); }); tearDown(() { store.shutdown(); }); // Tests go here } ``` ## Basic Test Pattern: Dispatch, Wait, Expect Use `dispatchAndWait()` to dispatch an action and wait for it to complete: ```dart test('SaveNameAction updates the name', () async { var store = Store( initialState: AppState(name: ''), ); await store.dispatchAndWait(SaveNameAction('John')); expect(store.state.name, 'John'); }); ``` ## Testing Async Actions Async actions work the same way - `dispatchAndWait()` returns only when the action fully completes: ```dart class FetchUserAction extends ReduxAction { final String userId; FetchUserAction(this.userId); Future reduce() async { var user = await api.fetchUser(userId); return state.copy(user: user); } } test('FetchUserAction loads user data', () async { var store = Store( initialState: AppState(user: null), ); await store.dispatchAndWait(FetchUserAction('123')); expect(store.state.user, isNotNull); expect(store.state.user!.id, '123'); }); ``` ## Testing Multiple Actions in Parallel Use `dispatchAndWaitAll()` to dispatch multiple actions and wait for all to complete: ```dart test('can buy and sell stocks in parallel', () async { var store = Store( initialState: AppState(portfolio: Portfolio.empty()), ); await store.dispatchAndWaitAll([ BuyAction('IBM', quantity: 10), SellAction('TSLA', quantity: 5), ]); expect(store.state.portfolio.holdings['IBM'], 10); expect(store.state.portfolio.holdings['TSLA'], isNull); }); ``` ## Verifying Action Errors with ActionStatus `dispatchAndWait()` returns an `ActionStatus` object that lets you verify if an action succeeded or failed: ```dart test('SaveAction fails with invalid data', () async { var store = Store( initialState: AppState.initialState(), ); var status = await store.dispatchAndWait(SaveAction(amount: -100)); expect(status.isCompletedFailed, isTrue); expect(status.isCompletedOk, isFalse); }); ``` ### ActionStatus Properties - **`isCompleted`**: Whether the action finished executing - **`isCompletedOk`**: True if action finished without errors (both `before()` and `reduce()` completed successfully) - **`isCompletedFailed`**: True if action threw an error - **`originalError`**: The error thrown by `before()` or `reduce()` - **`wrappedError`**: The error after `wrapError()` processing - **`hasFinishedMethodBefore`**: Whether `before()` completed - **`hasFinishedMethodReduce`**: Whether `reduce()` completed - **`hasFinishedMethodAfter`**: Whether `after()` completed ## Testing UserException Errors Test that actions throw appropriate `UserException` errors: ```dart class TransferMoney extends ReduxAction { final double amount; TransferMoney(this.amount); AppState? reduce() { if (amount <= 0) { throw UserException('Amount must be positive.'); } return state.copy(balance: state.balance - amount); } } test('TransferMoney throws UserException for invalid amount', () async { var store = Store( initialState: AppState(balance: 1000), ); var status = await store.dispatchAndWait(TransferMoney(0)); expect(status.isCompletedFailed, isTrue); var error = status.wrappedError; expect(error, isA()); expect((error as UserException).msg, 'Amount must be positive.'); }); ``` ## Testing Multiple Errors with Error Queue When multiple actions fail, check the store's error queue: ```dart test('multiple actions can fail', () async { var store = Store( initialState: AppState.initialState(), ); await store.dispatchAndWaitAll([ InvalidAction1(), InvalidAction2(), ]); // Check errors in the store's error queue expect(store.errors.length, 2); }); ``` ## Conditional Navigation After Action Success A common pattern is navigating only after an action succeeds: ```dart test('navigate only on successful save', () async { var store = Store( initialState: AppState.initialState(), ); var status = await store.dispatchAndWait(SaveAction(data: validData)); expect(status.isCompletedOk, isTrue); // In real code: if (status.isCompletedOk) Navigator.pop(context); }); ``` ## Testing State Unchanged on Error When an action throws, state should remain unchanged: ```dart test('state unchanged when action fails', () async { var store = Store( initialState: AppState(counter: 5), ); var initialState = store.state; await store.dispatchAndWait(FailingAction()); // State should not have changed expect(store.state.counter, 5); expect(store.state, initialState); }); ``` ## Using MockStore for Dependency Isolation Use `MockStore` to mock specific actions in tests: ```dart test('with mocked dependency action', () async { var store = MockStore( initialState: AppState.initialState(), mocks: { // Disable the action (don't run it) FetchFromServerAction: null, // Or replace with custom state modification FetchFromServerAction: (action, state) => state.copy(data: 'mocked data'), }, ); await store.dispatchAndWait(ActionThatDependsOnFetch()); expect(store.state.data, 'mocked data'); }); ``` ## Advanced Wait Methods for Complex Tests For complex async scenarios, use these additional wait methods: ```dart // Wait for a specific state condition await store.waitCondition((state) => state.isLoaded); // Wait for all given action types to complete await store.waitAllActionTypes([LoadAction, ProcessAction]); // Wait for any action of given types to finish await store.waitAnyActionTypeFinishes([LoadAction]); // Wait until no actions are in progress await store.waitAllActions([]); ``` ## Test File Organization Recommended naming convention for test files: - Widget: `my_feature.dart` - State tests: `my_feature_STATE_test.dart` - Connector tests: `my_feature_CONNECTOR_test.dart` - Presentation tests: `my_feature_PRESENTATION_test.dart` ## Complete Test Example ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:async_redux/async_redux.dart'; void main() { group('IncrementAction', () { late Store store; setUp(() { store = Store( initialState: AppState(counter: 0), ); }); test('increments counter by 1', () async { await store.dispatchAndWait(IncrementAction()); expect(store.state.counter, 1); }); test('increments counter multiple times', () async { await store.dispatchAndWait(IncrementAction()); await store.dispatchAndWait(IncrementAction()); await store.dispatchAndWait(IncrementAction()); expect(store.state.counter, 3); }); test('handles concurrent increments', () async { await store.dispatchAndWaitAll([ IncrementAction(), IncrementAction(), IncrementAction(), ]); expect(store.state.counter, 3); }); }); group('FetchDataAction', () { test('succeeds with valid response', () async { var store = Store( initialState: AppState(data: null), ); var status = await store.dispatchAndWait(FetchDataAction()); expect(status.isCompletedOk, isTrue); expect(store.state.data, isNotNull); }); test('fails gracefully on error', () async { var store = Store( initialState: AppState(data: null), ); var status = await store.dispatchAndWait( FetchDataAction(simulateError: true), ); expect(status.isCompletedFailed, isTrue); expect(status.wrappedError, isA()); expect(store.state.data, isNull); // State unchanged }); }); } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://asyncredux.com/flutter/testing/test-files - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/testing/testing-user-exceptions - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/miscellaneous/advanced-waiting ================================================ FILE: .claude/skills/asyncredux-testing-view-models/SKILL.md ================================================ --- name: asyncredux-testing-view-models description: Test StoreConnector view-models in isolation. Covers creating view-models with `Vm.createFrom()`, testing view-model properties, testing callbacks that dispatch actions, and verifying state changes from callbacks. --- # Testing View-Models in AsyncRedux View-models created by `VmFactory` can be tested in isolation without building widgets. Use `Vm.createFrom()` to instantiate the view-model directly, then verify properties and execute callbacks. ## Creating a View-Model for Testing Use `Vm.createFrom()` with a store and factory instance: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:async_redux/async_redux.dart'; test('view-model has correct properties', () { var store = Store( initialState: AppState(name: 'Mary', counter: 5), ); var vm = Vm.createFrom(store, CounterFactory()); expect(vm.counter, 5); expect(vm.name, 'Mary'); }); ``` **Important:** `Vm.createFrom()` can only be called once per factory instance. Create a new factory for each test. ## Testing View-Model Properties Verify that the factory correctly transforms state into view-model properties: ```dart class CounterViewModel extends Vm { final int counter; final String description; final VoidCallback onIncrement; CounterViewModel({ required this.counter, required this.description, required this.onIncrement, }) : super(equals: [counter, description]); } class CounterFactory extends VmFactory { @override CounterViewModel fromStore() => CounterViewModel( counter: state.counter, description: 'Count is ${state.counter}', onIncrement: () => dispatch(IncrementAction()), ); } test('factory transforms state correctly', () { var store = Store( initialState: AppState(counter: 10), ); var vm = Vm.createFrom(store, CounterFactory()); expect(vm.counter, 10); expect(vm.description, 'Count is 10'); }); ``` ## Testing Callbacks That Dispatch Actions When testing callbacks, invoke them and then use wait methods to verify actions were dispatched and state changed: ```dart test('onIncrement dispatches IncrementAction', () async { var store = Store( initialState: AppState(counter: 0), ); var vm = Vm.createFrom(store, CounterFactory()); // Invoke the callback vm.onIncrement(); // Wait for the action to complete await store.waitActionType(IncrementAction); // Verify state changed expect(store.state.counter, 1); }); ``` ## Wait Methods for Callback Testing Several wait methods help verify callback behavior: ### waitActionType Wait for a specific action type to finish: ```dart test('callback dispatches expected action', () async { var store = Store(initialState: AppState(name: '')); var vm = Vm.createFrom(store, UserFactory()); vm.onSave('John'); await store.waitActionType(SaveNameAction); expect(store.state.name, 'John'); }); ``` ### waitAllActionTypes Wait for multiple action types to complete: ```dart test('callback triggers multiple actions', () async { var store = Store(initialState: AppState.initialState()); var vm = Vm.createFrom(store, CheckoutFactory()); vm.onCheckout(); await store.waitAllActionTypes([ValidateCartAction, ProcessPaymentAction]); expect(store.state.orderCompleted, isTrue); }); ``` ### waitAnyActionTypeFinishes Wait for any matching action to finish, useful when testing actions that may or may not be dispatched: ```dart test('refresh triggers data fetch', () async { var store = Store(initialState: AppState.initialState()); var vm = Vm.createFrom(store, DataFactory()); vm.onRefresh(); var action = await store.waitAnyActionTypeFinishes([FetchDataAction]); expect(action, isA()); expect(store.state.data, isNotEmpty); }); ``` ### waitCondition Wait for state to meet a specific condition: ```dart test('loading completes when data is fetched', () async { var store = Store(initialState: AppState(isLoading: false, data: null)); var vm = Vm.createFrom(store, DataFactory()); vm.onLoad(); await store.waitCondition((state) => state.data != null); expect(store.state.isLoading, isFalse); expect(store.state.data, isNotNull); }); ``` ### waitAllActions Wait until no actions are in progress: ```dart test('all actions complete', () async { var store = Store(initialState: AppState.initialState()); var vm = Vm.createFrom(store, BatchFactory()); vm.onProcessBatch(); await store.waitAllActions([]); expect(store.state.batchProcessed, isTrue); }); ``` ## Testing Callbacks with Action Status Verify that callbacks dispatch actions that succeed or fail appropriately: ```dart test('save callback handles errors', () async { var store = Store( initialState: AppState(data: ''), ); var vm = Vm.createFrom(store, FormFactory()); // Trigger save with invalid data vm.onSave(''); // dispatchAndWait returns ActionStatus, but when testing callbacks, // use waitActionType and check store.errors await store.waitActionType(SaveAction); // Check if action failed expect(store.errors, isNotEmpty); }); ``` ## Testing Async Callbacks Async callbacks work the same way - wait for the dispatched actions: ```dart class UserFactory extends VmFactory { @override UserViewModel fromStore() => UserViewModel( user: state.user, onRefresh: () => dispatch(FetchUserAction()), ); } test('onRefresh loads user data', () async { var store = Store( initialState: AppState(user: null), ); var vm = Vm.createFrom(store, UserFactory()); vm.onRefresh(); await store.waitActionType(FetchUserAction); expect(store.state.user, isNotNull); }); ``` ## Testing with Mocked Actions Use `MockStore` to mock actions triggered by callbacks: ```dart test('callback with mocked dependency', () async { var store = MockStore( initialState: AppState(data: null), mocks: { // Mock the API call to return test data FetchDataAction: (action, state) => state.copy(data: 'mocked data'), }, ); var vm = Vm.createFrom(store, DataFactory()); vm.onFetch(); await store.waitActionType(FetchDataAction); expect(store.state.data, 'mocked data'); }); ``` ## Testing onInit and onDispose Lifecycle Use `ConnectorTester` to test lifecycle callbacks without building widgets: ```dart class MyScreen extends StatelessWidget { @override Widget build(BuildContext context) => StoreConnector( vm: () => MyFactory(), onInit: (store) => store.dispatch(StartPollingAction()), onDispose: (store) => store.dispatch(StopPollingAction()), builder: (context, vm) => MyWidget(vm: vm), ); } test('onInit dispatches StartPollingAction', () async { var store = Store(initialState: AppState.initialState()); var connectorTester = store.getConnectorTester(MyScreen()); connectorTester.runOnInit(); var action = await store.waitAnyActionTypeFinishes([StartPollingAction]); expect(action, isA()); }); test('onDispose dispatches StopPollingAction', () async { var store = Store(initialState: AppState.initialState()); var connectorTester = store.getConnectorTester(MyScreen()); connectorTester.runOnDispose(); var action = await store.waitAnyActionTypeFinishes([StopPollingAction]); expect(action, isA()); }); ``` ## Complete Test Example ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:async_redux/async_redux.dart'; // View-Model class TodoViewModel extends Vm { final List todos; final bool isLoading; final void Function(String) onAddTodo; final void Function(int) onRemoveTodo; final VoidCallback onRefresh; TodoViewModel({ required this.todos, required this.isLoading, required this.onAddTodo, required this.onRemoveTodo, required this.onRefresh, }) : super(equals: [todos, isLoading]); } // Factory class TodoFactory extends VmFactory { @override TodoViewModel fromStore() => TodoViewModel( todos: state.todos, isLoading: state.isLoading, onAddTodo: (text) => dispatch(AddTodoAction(text)), onRemoveTodo: (index) => dispatch(RemoveTodoAction(index)), onRefresh: () => dispatch(FetchTodosAction()), ); } void main() { group('TodoFactory', () { late Store store; setUp(() { store = Store( initialState: AppState(todos: [], isLoading: false), ); }); test('creates view-model with correct initial properties', () { var vm = Vm.createFrom(store, TodoFactory()); expect(vm.todos, isEmpty); expect(vm.isLoading, isFalse); }); test('onAddTodo dispatches AddTodoAction', () async { var vm = Vm.createFrom(store, TodoFactory()); vm.onAddTodo('Buy milk'); await store.waitActionType(AddTodoAction); expect(store.state.todos, contains('Buy milk')); }); test('onRemoveTodo dispatches RemoveTodoAction', () async { store = Store( initialState: AppState(todos: ['Task 1', 'Task 2'], isLoading: false), ); var vm = Vm.createFrom(store, TodoFactory()); vm.onRemoveTodo(0); await store.waitActionType(RemoveTodoAction); expect(store.state.todos, ['Task 2']); }); test('onRefresh fetches todos', () async { var vm = Vm.createFrom(store, TodoFactory()); vm.onRefresh(); await store.waitCondition((state) => !state.isLoading); expect(store.state.todos, isNotEmpty); }); }); } ``` ## Test Organization Follow the recommended naming convention for test files: - Widget: `todo_screen.dart` - Connector: `todo_screen_connector.dart` - State tests: `todo_screen_STATE_test.dart` - Connector tests: `todo_screen_CONNECTOR_test.dart` - Presentation tests: `todo_screen_PRESENTATION_test.dart` Connector tests focus on view-model logic - verifying properties are correctly derived from state and callbacks dispatch appropriate actions. ## References URLs from the documentation: - https://asyncredux.com/flutter/testing/testing-the-view-model - https://asyncredux.com/flutter/testing/testing-oninit-ondispose - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://asyncredux.com/flutter/testing/test-files - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/connector/store-connector - https://asyncredux.com/flutter/connector/advanced-view-model - https://asyncredux.com/flutter/connector/connector-pattern - https://asyncredux.com/flutter/miscellaneous/advanced-waiting ================================================ FILE: .claude/skills/asyncredux-testing-wait-methods/SKILL.md ================================================ --- name: asyncredux-testing-wait-methods description: Use advanced wait methods for complex test scenarios. Covers `waitCondition()`, `waitAllActions()`, `waitActionType()`, `waitAllActionTypes()`, `waitAnyActionTypeFinishes()`, and the `completeImmediately` parameter. --- # Advanced Wait Methods for Testing When testing complex async scenarios in AsyncRedux, the basic `dispatchAndWait()` may not be sufficient. The store provides several advanced wait methods for fine-grained control over when tests proceed. ## Overview of Wait Methods | Method | Purpose | |--------|---------| | `waitCondition()` | Wait until state meets a condition | | `waitAllActions()` | Wait for specific actions to complete, or until no actions are in progress | | `waitActionType()` | Wait until no action of a given type is in progress | | `waitAllActionTypes()` | Wait until no actions of the given types are in progress | | `waitAnyActionTypeFinishes()` | Wait until ANY action of given types finishes | | `waitActionCondition()` | Low-level: wait until actions in progress meet a custom condition | ## waitCondition() Waits until the state meets a given condition. Returns the action that triggered the state change. ```dart Future?> waitCondition( bool Function(St) condition, { bool completeImmediately = true, // Note: default is TRUE here int? timeoutMillis, }) ``` ### Basic Usage ```dart test('waitCondition waits for state to match', () async { var store = Store(initialState: AppState(count: 1)); // Dispatch an async action that will change the state store.dispatch(IncrementActionAsync()); // Wait until count becomes 2 var action = await store.waitCondition((state) => state.count == 2); expect(store.state.count, 2); expect(action, isA()); }); ``` ### Condition Already True By default, if the condition is already true, the future completes immediately: ```dart test('completes immediately when condition already true', () async { var store = Store(initialState: AppState(count: 5)); // Condition is already true - completes immediately await store.waitCondition((state) => state.count == 5); expect(store.state.count, 5); }); ``` ### Using completeImmediately: false To require that the condition must become true (not already be true): ```dart test('throws when condition already true with completeImmediately: false', () async { var store = Store(initialState: AppState(count: 1)); // This will throw because condition is already true expect( () => store.waitCondition( (state) => state.count == 1, completeImmediately: false, ), throwsA(isA()), ); }); ``` ## waitAllActions() Waits for specific actions to finish, or waits until no actions are in progress (when passed an empty list or null). ```dart Future waitAllActions( List>? actions, { bool completeImmediately = false, // Note: default is FALSE here int? timeoutMillis, }) ``` ### Wait for All Actions to Complete ```dart test('waitAllActions waits for all dispatched actions', () async { var store = Store(initialState: AppState(count: 1)); var action1 = DelayedIncrementAction(10, delayMillis: 50); var action2 = DelayedIncrementAction(100, delayMillis: 100); var action3 = DelayedIncrementAction(1000, delayMillis: 20); // Dispatch actions in parallel store.dispatch(action1); store.dispatch(action2); store.dispatch(action3); expect(store.state.count, 1); // Not changed yet // Wait for all three actions to finish await store.waitAllActions([action1, action2, action3]); expect(store.state.count, 1 + 10 + 100 + 1000); }); ``` ### Wait Until No Actions in Progress Pass an empty list or null to wait until no actions are running: ```dart test('waitAllActions with empty list waits for all to finish', () async { var store = Store(initialState: AppState(count: 1)); store.dispatch(DelayedAction(10, delayMillis: 50)); store.dispatch(DelayedAction(100, delayMillis: 100)); store.dispatch(DelayedAction(1000, delayMillis: 20)); expect(store.state.count, 1); // Wait until ALL actions finish (no actions in progress) await store.waitAllActions([]); expect(store.state.count, 1 + 10 + 100 + 1000); }); ``` ### Selective Waiting Wait for only some actions to finish, ignoring others: ```dart test('wait for specific actions only', () async { var store = Store(initialState: AppState(count: 1)); var action50 = DelayedAction(10, delayMillis: 50); var action100 = AnotherDelayedAction(100, delayMillis: 100); var action200 = SlowAction(100000, delayMillis: 200); // Very slow var action10 = DelayedAction(1000, delayMillis: 10); store.dispatch(action50); store.dispatch(action100); store.dispatch(action200); // We don't wait for this one store.dispatch(action10); // Wait for only the fast actions await store.waitAllActions([action50, action100, action10]); // The slow action hasn't finished yet expect(store.state.count, 1 + 10 + 100 + 1000); }); ``` ## waitActionType() Waits until no action of the given type is in progress. Returns the action that finished (or null if no action was in progress). ```dart Future?> waitActionType( Type actionType, { bool completeImmediately = false, int? timeoutMillis, }) ``` ### Basic Usage ```dart test('waitActionType waits for action type to finish', () async { var store = Store(initialState: AppState(count: 1)); store.dispatch(DelayedAction(1000, delayMillis: 10)); expect(store.state.count, 1); // Wait for any DelayedAction to finish var action = await store.waitActionType(DelayedAction); expect(store.state.count, 1001); expect(action, isA()); }); ``` ### Checking Action Status ```dart test('can check status of finished action', () async { var store = Store(initialState: AppState(count: 1)); store.dispatch(ActionThatMayFail()); var action = await store.waitActionType(ActionThatMayFail); expect(action?.status.isCompletedOk, isTrue); // Or check for errors: // expect(action?.status.originalError, isA()); }); ``` ### Waiting for Multiple Types Sequentially ```dart test('wait for multiple action types', () async { var store = Store(initialState: AppState(count: 1)); store.dispatch(AnotherDelayedAction(123, delayMillis: 100)); store.dispatch(DelayedAction(1000, delayMillis: 10)); expect(store.state.count, 1); // DelayedAction finishes first (10ms) await store.waitActionType(DelayedAction); expect(store.state.count, 1001); // AnotherDelayedAction finishes later (100ms) await store.waitActionType(AnotherDelayedAction); expect(store.state.count, 1124); }); ``` ## waitAllActionTypes() Waits until ALL actions of the given types are NOT in progress. ```dart Future waitAllActionTypes( List actionTypes, { bool completeImmediately = false, int? timeoutMillis, }) ``` ### Basic Usage ```dart test('waitAllActionTypes waits for all types', () async { var store = Store(initialState: AppState(count: 1)); store.dispatch(DelayedAction(10, delayMillis: 50)); store.dispatch(AnotherDelayedAction(100, delayMillis: 100)); store.dispatch(SlowAction(100000, delayMillis: 200)); store.dispatch(DelayedAction(1000, delayMillis: 10)); expect(store.state.count, 1); // Wait for DelayedAction and AnotherDelayedAction types only await store.waitAllActionTypes([DelayedAction, AnotherDelayedAction]); // SlowAction hasn't finished yet (200ms), but we didn't wait for it expect(store.state.count, 1 + 10 + 100 + 1000); }); ``` ## waitAnyActionTypeFinishes() **Important:** This method is different from the others. It waits until ANY action of the given types **finishes dispatching**, even if those actions weren't in progress when the method was called. ```dart Future> waitAnyActionTypeFinishes( List actionTypes, { int? timeoutMillis, }) ``` ### Use Case: Waiting for Nested Actions This is useful when an action dispatches other actions internally, and you want to wait for one of those nested actions to finish: ```dart test('waitAnyActionTypeFinishes waits for nested action', () async { var store = Store(initialState: AppState(count: 1)); // StartAction dispatches DelayedAction internally store.dispatch(StartAction()); // Wait for DelayedAction to finish (even though it wasn't dispatched yet) var action = await store.waitAnyActionTypeFinishes([DelayedAction]); expect(action, isA()); expect(action.status.isCompletedOk, isTrue); }); ``` ### Multiple Types - First One to Finish ```dart test('returns first action type to finish', () async { var store = Store(initialState: AppState()); store.dispatch(ProcessStocksAction()); // Dispatches BuyAction or SellAction // Wait for either BuyAction or SellAction to finish var action = await store.waitAnyActionTypeFinishes([BuyAction, SellAction]); expect(action.runtimeType, anyOf(equals(BuyAction), equals(SellAction))); }); ``` ## waitActionCondition() Low-level method that waits until the set of in-progress actions meets a custom condition. This is what the other wait methods use internally. ```dart Future<(Set>, ReduxAction?)> waitActionCondition( bool Function(Set> actions, ReduxAction? triggerAction) condition, { bool completeImmediately = false, String completedErrorMessage = "Awaited action condition was already true", int? timeoutMillis, }) ``` ### Example: Custom Condition ```dart test('waitActionCondition with custom condition', () async { var store = Store(initialState: AppState(count: 1)); // Wait until no actions are in progress await store.waitActionCondition( (actions, triggerAction) => actions.isEmpty, completeImmediately: true, ); }); ``` ## The completeImmediately Parameter This parameter controls behavior when the condition is already met when the method is called: | Method | Default | When `true` | When `false` | |--------|---------|-------------|--------------| | `waitCondition` | `true` | Completes immediately | Throws `StoreException` | | `waitAllActions` | `false` | Completes immediately | Throws `StoreException` | | `waitActionType` | `false` | Completes immediately, returns `null` | Throws `StoreException` | | `waitAllActionTypes` | `false` | Completes immediately | Throws `StoreException` | | `waitActionCondition` | `false` | Completes immediately | Throws `StoreException` | **Note:** `waitCondition` defaults to `true` because it's commonly used to check "is state ready?", where you want to proceed if it's already ready. The other methods default to `false` because they're typically used to wait for actions that should be in progress. ```dart test('completeImmediately behavior', () async { var store = Store(initialState: AppState(count: 1)); // waitCondition: completeImmediately defaults to TRUE await store.waitCondition((state) => state.count == 1); // OK, completes // waitAllActions: completeImmediately defaults to FALSE expect( () => store.waitAllActions([]), // No actions in progress throwsA(isA()), ); // Use completeImmediately: true to allow it await store.waitAllActions([], completeImmediately: true); // OK }); ``` ## Timeout Configuration All wait methods support a `timeoutMillis` parameter. The default timeout is 10 minutes. ```dart test('waitCondition with timeout', () async { var store = Store(initialState: AppState(count: 1)); // This condition will never be true, so it times out expect( () => store.waitCondition( (state) => state.count == 999, timeoutMillis: 10, // 10ms timeout ), throwsA(isA()), ); }); ``` ### Global Timeout Configuration Modify `Store.defaultTimeoutMillis` to change the default for all wait methods: ```dart void main() { // Set global default timeout to 30 seconds Store.defaultTimeoutMillis = 30 * 1000; // To disable timeout entirely, use -1 Store.defaultTimeoutMillis = -1; } ``` ## Complete Test Example ```dart import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Wait Methods', () { test('waitCondition waits for state change', () async { var store = Store(initialState: State(1)); // Dispatch async action store.dispatch(IncrementActionAsync()); // Wait for state to change await store.waitCondition((state) => state.count == 2); expect(store.state.count, 2); }); test('waitAllActions waits for all actions', () async { var store = Store(initialState: State(1)); store.dispatch(DelayedAction(10, delayMillis: 50)); store.dispatch(DelayedAction(100, delayMillis: 100)); store.dispatch(DelayedAction(1000, delayMillis: 20)); await store.waitAllActions([]); expect(store.state.count, 1111); }); test('waitActionType waits for specific type', () async { var store = Store(initialState: State(1)); store.dispatch(DelayedAction(1000, delayMillis: 10)); var action = await store.waitActionType(DelayedAction); expect(store.state.count, 1001); expect(action?.status.isCompletedOk, isTrue); }); test('waitAllActionTypes waits for multiple types', () async { var store = Store(initialState: State(1)); store.dispatch(DelayedAction(10, delayMillis: 50)); store.dispatch(AnotherAction(100, delayMillis: 100)); await store.waitAllActionTypes([DelayedAction, AnotherAction]); expect(store.state.count, 111); }); test('waitAnyActionTypeFinishes waits for first finish', () async { var store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 10)); var action = await store.waitAnyActionTypeFinishes([DelayedAction]); expect(action, isA()); expect(action.status.isCompletedOk, isTrue); }); }); } // Test state and actions class State { final int count; State(this.count); } class IncrementActionAsync extends ReduxAction { @override Future reduce() async { await Future.delayed(Duration(milliseconds: 10)); return State(state.count + 1); } } class DelayedAction extends ReduxAction { final int increment; final int delayMillis; DelayedAction(this.increment, {required this.delayMillis}); @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMillis)); return State(state.count + increment); } } class AnotherAction extends DelayedAction { AnotherAction(int increment, {required int delayMillis}) : super(increment, delayMillis: delayMillis); } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/miscellaneous/wait-condition - https://asyncredux.com/flutter/miscellaneous/advanced-waiting - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/basics/dispatching-actions ================================================ FILE: .claude/skills/asyncredux-throttle-mixin/SKILL.md ================================================ --- name: asyncredux-throttle-mixin description: Add the Throttle mixin to prevent actions from running too frequently. Covers setting the throttle duration in milliseconds, use cases like price refresh, and how freshness/staleness works. --- # Throttle Mixin The `Throttle` mixin limits action execution to at most once per throttle period. When an action is dispatched multiple times within the defined window, only the first execution runs while subsequent calls abort silently. After the period expires, the next dispatch is permitted. ## Basic Usage ```dart class LoadPrices extends AppAction with Throttle { // Throttle period in milliseconds (default is 1000ms) int get throttle => 5000; // 5 seconds Future reduce() async { var prices = await fetchCurrentPrices(); return state.copy(prices: prices); } } ``` The default throttle duration is **1000 milliseconds** (1 second). Override the `throttle` getter to set a custom duration. ## How Throttle Works (Freshness/Staleness) Throttle uses a "freshness window" concept: 1. **First dispatch**: Action runs immediately, data becomes "fresh" 2. **During throttle period**: Data is considered fresh, subsequent dispatches are aborted 3. **After throttle period expires**: Data becomes "stale", next dispatch is allowed to run This ensures that frequently triggered actions (like a "Refresh Prices" button) don't overwhelm your server while still allowing updates after a reasonable interval. ```dart // User taps "Refresh" rapidly 5 times in 2 seconds // With a 5-second throttle: // - 1st tap: Action runs, prices update // - 2nd-5th taps: Silently aborted (data still "fresh") // - Tap after 5 seconds: Action runs again (data now "stale") ``` ## Throttle vs Debounce | Aspect | Throttle | Debounce | |--------|----------|----------| | **When it runs** | Immediately on first dispatch | After dispatches stop | | **Blocking** | Blocks for the period after running | Resets timer on each dispatch | | **Use case** | Price refresh, rate-limited APIs | Search-as-you-type | ## Bypassing Throttle Override `ignoreThrottle` to conditionally skip rate limiting: ```dart class LoadPrices extends AppAction with Throttle { final bool forceRefresh; LoadPrices({this.forceRefresh = false}); int get throttle => 5000; // Bypass throttle when force refresh is requested bool get ignoreThrottle => forceRefresh; Future reduce() async { var prices = await fetchCurrentPrices(); return state.copy(prices: prices); } } // Normal dispatch - respects throttle dispatch(LoadPrices()); // Force refresh - ignores throttle dispatch(LoadPrices(forceRefresh: true)); ``` ## Failure Handling By default, the throttle lock persists even after errors, preventing immediate retry: ```dart class LoadPrices extends AppAction with Throttle { int get throttle => 5000; // Allow immediate retry if the action fails bool get removeLockOnError => true; Future reduce() async { var prices = await fetchCurrentPrices(); return state.copy(prices: prices); } } ``` ### Manual Lock Control For more control, use these methods: ```dart // Remove the lock for this specific action type removeLock(); // Remove locks for all throttled actions removeAllLocks(); ``` ## Custom Locking Strategies Override `lockBuilder()` to implement different locking behaviors: ```dart class LoadPricesForSymbol extends AppAction with Throttle { final String symbol; LoadPricesForSymbol(this.symbol); int get throttle => 5000; // Use the symbol as part of the lock key // This allows throttling per symbol instead of per action type Object lockBuilder() => 'LoadPrices_$symbol'; Future reduce() async { var price = await fetchPrice(symbol); return state.copy(prices: state.prices.add(symbol, price)); } } // These can run in parallel (different lock keys): dispatch(LoadPricesForSymbol('AAPL')); dispatch(LoadPricesForSymbol('GOOGL')); // But this will be throttled (same lock key as first): dispatch(LoadPricesForSymbol('AAPL')); // Aborted if within 5 seconds ``` ## Common Use Cases ### Price/Data Refresh ```dart class RefreshStockPrices extends AppAction with Throttle { int get throttle => 10000; // At most once every 10 seconds Future reduce() async { var prices = await stockApi.getAllPrices(); return state.copy(stockPrices: prices); } } ``` ### Rate-Limited API Calls ```dart class SyncWithServer extends AppAction with Throttle { int get throttle => 30000; // At most once every 30 seconds Future reduce() async { var data = await api.sync(); return state.copy(lastSync: DateTime.now(), data: data); } } ``` ### Preventing Button Spam ```dart class SubmitFeedback extends AppAction with Throttle { final String feedback; SubmitFeedback(this.feedback); int get throttle => 60000; // At most once per minute Future reduce() async { await api.submitFeedback(feedback); return state.copy(feedbackSubmitted: true); } } ``` ## Mixin Compatibility **Compatible with:** - `CheckInternet` - `NoDialog` - `AbortWhenNoInternet` - `Retry` - `UnlimitedRetries` - `Debounce` **Incompatible with:** - `NonReentrant` (use one or the other, not both) - `OptimisticUpdate` - `OptimisticSync` - `OptimisticSyncWithPush` ## Combining Multiple Mixins ```dart class LoadPrices extends AppAction with CheckInternet, Throttle, Retry { int get throttle => 5000; Future reduce() async { // CheckInternet ensures connectivity // Throttle prevents excessive calls // Retry handles transient failures var prices = await fetchCurrentPrices(); return state.copy(prices: prices); } } ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/action-mixins - https://asyncredux.com/flutter/advanced-actions/control-mixins - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/advanced-actions/aborting-the-dispatch - https://asyncredux.com/flutter/advanced-actions/optimistic-mixins - https://asyncredux.com/flutter/advanced-actions/internet-mixins - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/miscellaneous/refresh-indicators - https://asyncredux.com/flutter/miscellaneous/database-and-cloud - https://asyncredux.com/flutter/miscellaneous/wait-condition - https://asyncredux.com/flutter/testing/mocking - https://asyncredux.com/flutter/about - https://asyncredux.com/flutter/intro ================================================ FILE: .claude/skills/asyncredux-undo-redo/SKILL.md ================================================ --- name: asyncredux-undo-redo description: Implement undo/redo functionality using state observers. Covers recording state history with stateObserver, creating a RecoverStateAction, implementing undo for the full state or partial state, and managing history limits. --- # Undo and Redo in AsyncRedux AsyncRedux simplifies undo/redo through a straightforward pattern: create a state observer to save states to a history list, and create actions to navigate through that history. ## Understanding StateObserver The `StateObserver` abstract class tracks state modifications. Implement its `observe` method to be notified of state changes: ```dart abstract class StateObserver { void observe( ReduxAction action, St stateIni, St stateEnd, Object? error, int dispatchCount, ); } ``` **Parameters:** - `action` - The dispatched action that triggered the state change - `stateIni` - The state before the reducer applied changes - `stateEnd` - The new state returned by the reducer - `error` - Null if successful; contains the thrown error otherwise - `dispatchCount` - Sequential dispatch number **Timing:** The observer fires right after the reducer returns, before both the `after()` method and error-wrapping processes. ## Step 1: Create the UndoRedoObserver ```dart class UndoRedoObserver implements StateObserver { final List _history = []; int _currentIndex = -1; final int maxHistorySize; UndoRedoObserver({this.maxHistorySize = 50}); @override void observe( ReduxAction action, AppState stateIni, AppState stateEnd, Object? error, int dispatchCount, ) { // Skip if action had an error if (error != null) return; // Skip if state didn't change if (stateIni == stateEnd) return; // Skip undo/redo actions to prevent recursive history entries if (action is UndoAction || action is RedoAction) return; // When navigating backwards then performing new actions, // clear the "future" history if (_currentIndex < _history.length - 1) { _history.removeRange(_currentIndex + 1, _history.length); } // Add the new state to history _history.add(stateEnd); _currentIndex = _history.length - 1; // Enforce maximum history size by removing oldest entries while (_history.length > maxHistorySize) { _history.removeAt(0); _currentIndex--; } } /// Returns the previous state, or null if at the beginning AppState? getPreviousState() { if (_currentIndex > 0) { _currentIndex--; return _history[_currentIndex]; } return null; } /// Returns the next state, or null if at the end AppState? getNextState() { if (_currentIndex < _history.length - 1) { _currentIndex++; return _history[_currentIndex]; } return null; } bool get canUndo => _currentIndex > 0; bool get canRedo => _currentIndex < _history.length - 1; } ``` ## Step 2: Register the Observer with the Store Pass the observer to `stateObservers` during store creation: ```dart // Create the observer instance so actions can access it final undoRedoObserver = UndoRedoObserver(maxHistorySize: 100); var store = Store( initialState: AppState.initialState(), stateObservers: [undoRedoObserver], ); ``` ## Step 3: Create Navigation Actions Create `UndoAction` and `RedoAction` that retrieve states from history: ```dart class UndoAction extends ReduxAction { final UndoRedoObserver observer; UndoAction(this.observer); @override AppState? reduce() { return observer.getPreviousState(); } } class RedoAction extends ReduxAction { final UndoRedoObserver observer; RedoAction(this.observer); @override AppState? reduce() { return observer.getNextState(); } } ``` **Alternative:** Access the observer through dependency injection using the environment pattern: ```dart class UndoAction extends ReduxAction { @override AppState? reduce() { final observer = env.undoRedoObserver; return observer.getPreviousState(); } } ``` ## Step 4: Integrate with the UI Dispatch undo/redo actions from widgets: ```dart class UndoRedoButtons extends StatelessWidget { final UndoRedoObserver observer; const UndoRedoButtons({required this.observer}); @override Widget build(BuildContext context) { return Row( children: [ IconButton( icon: Icon(Icons.undo), onPressed: observer.canUndo ? () => context.dispatch(UndoAction(observer)) : null, ), IconButton( icon: Icon(Icons.redo), onPressed: observer.canRedo ? () => context.dispatch(RedoAction(observer)) : null, ), ], ); } } ``` ## Partial State Undo/Redo The same approach works to undo/redo only **part** of the state. This is useful when you want to track changes to a specific slice of state independently. ```dart class PartialUndoRedoObserver implements StateObserver { final List _history = []; int _currentIndex = -1; final int maxHistorySize; PartialUndoRedoObserver({this.maxHistorySize = 50}); @override void observe( ReduxAction action, AppState stateIni, AppState stateEnd, Object? error, int dispatchCount, ) { if (error != null) return; if (action is UndoDocumentAction || action is RedoDocumentAction) return; // Only track changes to the document portion of state if (stateIni.document == stateEnd.document) return; if (_currentIndex < _history.length - 1) { _history.removeRange(_currentIndex + 1, _history.length); } _history.add(stateEnd.document); _currentIndex = _history.length - 1; while (_history.length > maxHistorySize) { _history.removeAt(0); _currentIndex--; } } DocumentState? getPreviousDocument() { if (_currentIndex > 0) { _currentIndex--; return _history[_currentIndex]; } return null; } DocumentState? getNextDocument() { if (_currentIndex < _history.length - 1) { _currentIndex++; return _history[_currentIndex]; } return null; } } class UndoDocumentAction extends ReduxAction { final PartialUndoRedoObserver observer; UndoDocumentAction(this.observer); @override AppState? reduce() { final previousDoc = observer.getPreviousDocument(); if (previousDoc == null) return null; return state.copy(document: previousDoc); } } ``` ## Managing History Limits Key considerations for history management: 1. **Set appropriate limits** - Balance memory usage with undo depth needs 2. **Remove oldest entries** - When exceeding the limit, remove from the beginning 3. **Clear future history** - When new actions occur after undoing, discard the redo stack 4. **Filter irrelevant actions** - Skip actions that don't change state or are navigation actions ```dart // Example: Different limits for different use cases final documentObserver = UndoRedoObserver(maxHistorySize: 100); // Heavy undo final preferencesObserver = UndoRedoObserver(maxHistorySize: 10); // Light undo ``` ## Complete Example ```dart // observer.dart class UndoRedoObserver implements StateObserver { final List _history = []; int _currentIndex = -1; final int maxHistorySize; UndoRedoObserver({this.maxHistorySize = 50}); @override void observe( ReduxAction action, AppState stateIni, AppState stateEnd, Object? error, int dispatchCount, ) { if (error != null) return; if (stateIni == stateEnd) return; if (action is UndoAction || action is RedoAction) return; if (_currentIndex < _history.length - 1) { _history.removeRange(_currentIndex + 1, _history.length); } _history.add(stateEnd); _currentIndex = _history.length - 1; while (_history.length > maxHistorySize) { _history.removeAt(0); _currentIndex--; } } AppState? getPreviousState() { if (_currentIndex > 0) { _currentIndex--; return _history[_currentIndex]; } return null; } AppState? getNextState() { if (_currentIndex < _history.length - 1) { _currentIndex++; return _history[_currentIndex]; } return null; } bool get canUndo => _currentIndex > 0; bool get canRedo => _currentIndex < _history.length - 1; void clear() { _history.clear(); _currentIndex = -1; } } // actions.dart class UndoAction extends ReduxAction { @override AppState? reduce() => env.undoRedoObserver.getPreviousState(); } class RedoAction extends ReduxAction { @override AppState? reduce() => env.undoRedoObserver.getNextState(); } // main.dart void main() { final undoRedoObserver = UndoRedoObserver(maxHistorySize: 100); final store = Store( initialState: AppState.initialState(), stateObservers: [undoRedoObserver], environment: Environment(undoRedoObserver: undoRedoObserver), ); runApp( StoreProvider( store: store, child: MyApp(), ), ); } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/miscellaneous/undo-and-redo - https://asyncredux.com/flutter/basics/store - https://asyncredux.com/flutter/miscellaneous/logging - https://asyncredux.com/flutter/miscellaneous/metrics - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/basics/sync-actions - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer - https://asyncredux.com/flutter/basics/async-actions ================================================ FILE: .claude/skills/asyncredux-user-exceptions/SKILL.md ================================================ --- name: asyncredux-user-exceptions description: Handle user-facing errors with UserException. Covers throwing UserException from actions, setting up UserExceptionDialog, customizing error dialogs with `onShowUserExceptionDialog`, and using UserExceptionAction for non-interrupting error display. --- # UserException in AsyncRedux `UserException` is a special error type for user-facing errors that should be displayed to the user rather than logged as bugs. These represent issues the user can address or should be informed about. ## Throwing UserException from Actions Throw `UserException` when an action encounters a user-facing error: ```dart class TransferMoney extends AppAction { final double amount; TransferMoney(this.amount); AppState? reduce() { if (amount == 0) { throw UserException('You cannot transfer zero money.'); } return state.copy(cash: state.cash - amount); } } ``` For async actions with validation: ```dart class SaveUser extends AppAction { final String name; SaveUser(this.name); Future reduce() async { if (name.length < 4) throw UserException('Name must have at least 4 letters.'); await saveUser(name); return null; } } ``` ## Converting Errors to UserException Use `addCause()` to preserve the original error while showing a user-friendly message: ```dart class ConvertAction extends AppAction { final String text; ConvertAction(this.text); Future reduce() async { try { var value = int.parse(text); return state.copy(counter: value); } catch (error) { throw UserException('Please enter a valid number') .addCause(error); } } } ``` ## Setting Up UserExceptionDialog Wrap your home page with `UserExceptionDialog` below both `StoreProvider` and `MaterialApp`: ```dart Widget build(context) { return StoreProvider( store: store, child: MaterialApp( home: UserExceptionDialog( child: MyHomePage(), ), ), ); } ``` If you omit the `onShowUserExceptionDialog` parameter, a default dialog appears with the error message and an OK button. ## Customizing Error Dialogs Use `onShowUserExceptionDialog` to create custom error dialogs: ```dart UserExceptionDialog( onShowUserExceptionDialog: (BuildContext context, UserException exception) { showDialog( context: context, builder: (context) => AlertDialog( title: Text('Error'), content: Text(exception.message ?? 'An error occurred'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('OK'), ), ], ), ); }, child: MyHomePage(), ) ``` For non-standard error presentation (like snackbars or banners), you can modify the behavior by accessing the `didUpdateWidget` method in a custom implementation. ## UserExceptionAction for Non-Interrupting Errors Use `UserExceptionAction` to show an error dialog without throwing an exception or stopping action execution: ```dart // Show error dialog without failing the action dispatch(UserExceptionAction('Please enter a valid number')); ``` This is useful when you want to notify the user of an issue mid-action while continuing execution: ```dart class ConvertAction extends AppAction { final String text; ConvertAction(this.text); Future reduce() async { var value = int.tryParse(text); if (value == null) { // Shows dialog but action continues dispatch(UserExceptionAction('Invalid number, using default')); value = 0; } return state.copy(counter: value); } } ``` ## Reusable Error Handling with Mixins Create mixins to standardize UserException conversion across actions: ```dart mixin ShowUserException on AppAction { String getErrorMessage(); Object? wrapError(Object error, StackTrace stackTrace) { return UserException(getErrorMessage()).addCause(error); } } class ConvertAction extends AppAction with ShowUserException { final String text; ConvertAction(this.text); @override String getErrorMessage() => 'Please enter a valid number.'; Future reduce() async { var value = int.parse(text); // Any error becomes UserException return state.copy(counter: value); } } ``` ## Global Error Handling with GlobalWrapError Handle third-party or framework errors uniformly across all actions: ```dart var store = Store( initialState: AppState.initialState(), globalWrapError: MyGlobalWrapError(), ); class MyGlobalWrapError extends GlobalWrapError { @override Object? wrap(Object error, StackTrace stackTrace, ReduxAction action) { if (error is PlatformException && error.code == 'Error performing get') { return UserException('Check your internet connection') .addCause(error); } // Return the error unchanged for other cases return error; } } ``` **Processing order**: Action's `wrapError()` -> `GlobalWrapError` -> `ErrorObserver` ## Error Queue Thrown `UserException` instances are stored in a dedicated error queue within the store. The queue is consumed by `UserExceptionDialog` to display error messages. You can configure the maximum queue capacity in the Store constructor. ## Checking Failed Actions in Widgets Use these methods to check action failure status and display errors inline: ```dart Widget build(BuildContext context) { if (context.isFailed(SaveUserAction)) { var exception = context.exceptionFor(SaveUserAction); return Column( children: [ Text('Failed: ${exception?.message}'), ElevatedButton( onPressed: () { context.clearExceptionFor(SaveUserAction); context.dispatch(SaveUserAction(name)); }, child: Text('Retry'), ), ], ); } return Text('User saved successfully'); } ``` Note: Error states automatically clear when an action is redispatched, so manual cleanup before retry is usually unnecessary. ## Testing UserExceptions Test that actions throw `UserException` correctly: ```dart test('should throw UserException for invalid input', () async { var store = Store(initialState: AppState.initialState()); var status = await store.dispatchAndWait(TransferMoney(0)); expect(status.isCompletedFailed, isTrue); var error = status.wrappedError; expect(error, isA()); expect((error as UserException).message, 'You cannot transfer zero money.'); }); ``` Test multiple exceptions using the error queue: ```dart test('should collect multiple UserExceptions', () async { var store = Store(initialState: AppState.initialState()); await store.dispatchAndWaitAll([ InvalidAction1(), InvalidAction2(), InvalidAction3(), ]); var errors = store.errors; expect(errors.length, 3); expect(errors[0].message, 'First error message'); }); ``` ## References URLs from the documentation: - https://asyncredux.com/sitemap.xml - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/testing/testing-user-exceptions - https://asyncredux.com/flutter/basics/wait-fail-succeed - https://asyncredux.com/flutter/advanced-actions/wrapping-the-reducer - https://asyncredux.com/flutter/basics/store ================================================ FILE: .claude/skills/asyncredux-wait-condition/SKILL.md ================================================ --- name: asyncredux-wait-condition description: Use `waitCondition()` inside actions to pause execution until state meets criteria. Covers waiting for price thresholds, coordinating between actions, and implementing conditional workflows. --- # Waiting for State Conditions with waitCondition() The `waitCondition()` method pauses execution until the application state satisfies a specific condition. It's available on both the `Store` and `ReduxAction` classes. ## Method Signature ```dart Future?> waitCondition( bool Function(St) condition, { bool completeImmediately = true, int? timeoutMillis, }); ``` **Parameters:** - **condition**: A function that takes the current state and returns `true` when the desired condition is met - **completeImmediately**: If `true` (default), completes immediately when the condition is already satisfied. If `false`, waits for a state change to meet the condition - **timeoutMillis**: Maximum time to wait (defaults to 10 minutes). Set to `-1` to disable timeout **Returns:** The action that triggered the condition to become true, or `null` if condition was already met. ## Basic Usage Inside an Action Use `waitCondition()` when your action needs to wait for a prerequisite state before proceeding: ```dart class AddAppointmentAction extends ReduxAction { final String title; final DateTime date; AddAppointmentAction({required this.title, required this.date}); @override Future reduce() async { // Ensure calendar exists before adding appointment if (state.calendar == null) { dispatch(CreateCalendarAction()); // Wait until calendar is available await waitCondition((state) => state.calendar != null); } // Now safe to add the appointment return state.copy( calendar: state.calendar!.addAppointment( Appointment(title: title, date: date), ), ); } } ``` ## Waiting for Value Thresholds Wait for numeric values to reach specific thresholds: ```dart class ExecuteTradeAction extends ReduxAction { final double targetPrice; ExecuteTradeAction(this.targetPrice); @override Future reduce() async { // Wait until stock price reaches target await waitCondition((state) => state.stockPrice >= targetPrice); // Execute the trade at or above target price return state.copy( tradeExecuted: true, executionPrice: state.stockPrice, ); } } ``` ## Coordinating Between Actions Use `waitCondition()` to coordinate dependent actions: ```dart class ProcessOrderAction extends ReduxAction { @override Future reduce() async { // Dispatch parallel data loading dispatch(LoadInventoryAction()); dispatch(LoadPricingAction()); // Wait for both to complete await waitCondition((state) => state.inventoryLoaded && state.pricingLoaded ); // Both are now available - proceed with order processing final total = calculateTotal(state.inventory, state.pricing); return state.copy(orderTotal: total); } } ``` ## Implementing Conditional Workflows Create multi-step workflows that wait for user input or external events: ```dart class CheckoutWorkflowAction extends ReduxAction { @override Future reduce() async { // Step 1: Wait for cart to be ready await waitCondition((state) => state.cart.isNotEmpty); // Step 2: Start payment processing dispatch(InitiatePaymentAction()); // Step 3: Wait for payment confirmation await waitCondition((state) => state.paymentStatus == PaymentStatus.confirmed || state.paymentStatus == PaymentStatus.failed ); if (state.paymentStatus == PaymentStatus.failed) { throw UserException('Payment failed. Please try again.'); } // Step 4: Complete the order return state.copy(orderCompleted: true); } } ``` ## Using the Return Value `waitCondition()` returns the action that caused the condition to become true: ```dart class MonitorPriceAction extends ReduxAction { @override Future reduce() async { // Wait for price change and get the action that changed it final triggeringAction = await waitCondition( (state) => state.price > 100, ); // Can inspect which action triggered the condition if (triggeringAction is PriceUpdateAction) { print('Price updated by: ${triggeringAction.source}'); } return state.copy(alertTriggered: true); } } ``` ## Using completeImmediately Parameter Control behavior when the condition is already met: ```dart class WaitForNewDataAction extends ReduxAction { @override Future reduce() async { // completeImmediately: false means wait for a NEW state change // even if condition is currently satisfied await waitCondition( (state) => state.dataVersion > 0, completeImmediately: false, // Wait for fresh data ); return state.copy(dataProcessed: true); } } ``` ## Setting Timeouts Prevent indefinite waiting with custom timeouts: ```dart class TimeSensitiveAction extends ReduxAction { @override Future reduce() async { try { // Wait maximum 5 seconds for condition await waitCondition( (state) => state.isReady, timeoutMillis: 5000, ); } catch (e) { // Timeout exceeded - handle gracefully throw UserException('Operation timed out. Please try again.'); } return state.copy(processed: true); } } ``` ## Using waitCondition() from the Store In tests or widgets, call `waitCondition()` directly on the store: ```dart // In a test test('waits for data to load', () async { var store = Store(initialState: AppState.initial()); store.dispatch(LoadDataAction()); // Wait for loading to complete await store.waitCondition((state) => state.isLoaded); expect(store.state.data, isNotNull); }); ``` ## Testing with waitCondition() `waitCondition()` is useful in tests to wait for expected state: ```dart test('processes order after inventory loads', () async { var store = Store( initialState: AppState(inventoryLoaded: false), ); // Start the process store.dispatch(ProcessOrderAction()); // Simulate inventory loading await Future.delayed(Duration(milliseconds: 100)); store.dispatch(LoadInventoryCompleteAction()); // Wait for order processing to complete await store.waitCondition((state) => state.orderProcessed); expect(store.state.orderTotal, greaterThan(0)); }); ``` ## Comparison with Other Wait Methods | Method | Use Case | |--------|----------| | `waitCondition()` | Wait for state to satisfy a predicate | | `dispatchAndWait()` | Wait for a specific action to complete | | `waitAllActions([])` | Wait for all current actions to finish | | `waitActionType()` | Wait for an action of a specific type | ## Common Patterns ### Wait for Initialization ```dart class AppStartupAction extends ReduxAction { @override Future reduce() async { dispatch(LoadUserAction()); dispatch(LoadSettingsAction()); dispatch(LoadCacheAction()); // Wait for all initialization to complete await waitCondition((state) => state.user != null && state.settings != null && state.cacheReady ); return state.copy(appReady: true); } } ``` ### Wait for User Confirmation ```dart class DeleteAccountAction extends ReduxAction { @override Future reduce() async { // Show confirmation dialog dispatch(ShowConfirmationDialogAction( message: 'Are you sure you want to delete your account?', )); // Wait for user response await waitCondition((state) => state.confirmationResult != null ); if (state.confirmationResult != true) { return null; // User cancelled } // Proceed with deletion await api.deleteAccount(); return state.copy(accountDeleted: true); } } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/miscellaneous/wait-condition - https://asyncredux.com/flutter/miscellaneous/advanced-waiting - https://asyncredux.com/flutter/advanced-actions/redux-action - https://asyncredux.com/flutter/testing/store-tester - https://asyncredux.com/flutter/testing/dispatch-wait-and-expect - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/advanced-actions/before-and-after-the-reducer ================================================ FILE: .claude/skills/asyncredux-wait-fail-succeed/SKILL.md ================================================ --- name: asyncredux-wait-fail-succeed description: Show loading states and handle action failures in widgets. Covers `isWaiting(ActionType)` for spinners, `isFailed(ActionType)` for error states, `exceptionFor(ActionType)` for error messages, and `clearExceptionFor()` to reset failure states. --- # AsyncRedux Wait, Fail, Succeed AsyncRedux provides context extension methods to track async action states: waiting (in progress), failed (error), and succeeded (complete). These are essential for showing spinners, error messages, and success states in the UI. ## Four Core Methods | Method | Returns | Purpose | |--------|---------|---------| | `isWaiting(ActionType)` | `bool` | True if the action is currently running | | `isFailed(ActionType)` | `bool` | True if the action recently failed | | `exceptionFor(ActionType)` | `UserException?` | The exception from a failed action | | `clearExceptionFor(ActionType)` | `void` | Manually clears stored exception | ## Showing a Loading Spinner Use `isWaiting()` to display a spinner while an action runs: ```dart Widget build(BuildContext context) { if (context.isWaiting(FetchDataAction)) { return CircularProgressIndicator(); } return Text('Data: ${context.state.data}'); } ``` The widget automatically rebuilds when the action starts and completes. ## Showing Error States Use `isFailed()` and `exceptionFor()` to display error messages: ```dart Widget build(BuildContext context) { if (context.isFailed(FetchDataAction)) { var exception = context.exceptionFor(FetchDataAction); return Text('Error: ${exception?.message}'); } return Text('Data: ${context.state.data}'); } ``` ## Combined Pattern: Loading, Error, and Success The typical pattern handles all three states: ```dart Widget build(BuildContext context) { // Loading state if (context.isWaiting(GetItemsAction)) { return Center(child: CircularProgressIndicator()); } // Error state with retry if (context.isFailed(GetItemsAction)) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Failed to load items'), Text(context.exceptionFor(GetItemsAction)?.message ?? ''), ElevatedButton( onPressed: () => context.dispatch(GetItemsAction()), child: Text('Retry'), ), ], ); } // Success state return ListView.builder( itemCount: context.state.items.length, itemBuilder: (context, index) => ListTile( title: Text(context.state.items[index].name), ), ); } ``` ## Automatic Error Clearing When an action is dispatched again, any previous error for that action type is automatically cleared. This means: - User sees error - User taps "Retry" which dispatches the action again - `isFailed()` becomes false immediately - `isWaiting()` becomes true - If action succeeds, widget shows success state - If action fails again, `isFailed()` becomes true with the new exception ## Manual Error Clearing Use `clearExceptionFor()` when you need to dismiss an error without retrying: ```dart Widget build(BuildContext context) { if (context.isFailed(SubmitFormAction)) { return AlertDialog( title: Text('Error'), content: Text(context.exceptionFor(SubmitFormAction)?.message ?? ''), actions: [ TextButton( onPressed: () { context.clearExceptionFor(SubmitFormAction); }, child: Text('Dismiss'), ), TextButton( onPressed: () => context.dispatch(SubmitFormAction()), child: Text('Retry'), ), ], ); } // ... } ``` ## How Actions Fail Actions fail when they throw an error in `before()` or `reduce()`. Use `UserException` for user-facing errors: ```dart class FetchDataAction extends ReduxAction { @override Future reduce() async { final response = await api.fetchData(); if (response.statusCode == 404) { throw UserException('Data not found.'); } if (response.statusCode != 200) { throw UserException('Failed to load data. Please try again.'); } return state.copy(data: response.data); } } ``` ## Checking Multiple Actions You can check multiple action types for waiting or failure: ```dart Widget build(BuildContext context) { // Check if any of several actions are running bool isLoading = context.isWaiting(FetchUserAction) || context.isWaiting(FetchSettingsAction); if (isLoading) { return CircularProgressIndicator(); } // Check for any failures if (context.isFailed(FetchUserAction)) { return Text('Failed to load user'); } if (context.isFailed(FetchSettingsAction)) { return Text('Failed to load settings'); } return MyContent(); } ``` ## Pull-to-Refresh Integration Combine with `dispatchAndWait()` for refresh indicators: ```dart class MyListWidget extends StatelessWidget { Future _onRefresh(BuildContext context) { return context.dispatchAndWait(RefreshItemsAction()); } @override Widget build(BuildContext context) { return RefreshIndicator( onRefresh: () => _onRefresh(context), child: ListView.builder( itemCount: context.state.items.length, itemBuilder: (context, index) => ListTile( title: Text(context.state.items[index].name), ), ), ); } } ``` ## Complete Example ```dart class LoadProductsAction extends ReduxAction { @override Future reduce() async { final products = await api.fetchProducts(); if (products.isEmpty) { throw UserException('No products available.'); } return state.copy(products: products); } } class ProductsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Products')), body: _buildBody(context), floatingActionButton: FloatingActionButton( onPressed: () => context.dispatch(LoadProductsAction()), child: Icon(Icons.refresh), ), ); } Widget _buildBody(BuildContext context) { if (context.isWaiting(LoadProductsAction)) { return Center(child: CircularProgressIndicator()); } if (context.isFailed(LoadProductsAction)) { final error = context.exceptionFor(LoadProductsAction); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error, size: 64, color: Colors.red), SizedBox(height: 16), Text(error?.message ?? 'An error occurred'), SizedBox(height: 16), ElevatedButton( onPressed: () => context.dispatch(LoadProductsAction()), child: Text('Try Again'), ), ], ), ); } final products = context.state.products; if (products.isEmpty) { return Center(child: Text('No products yet. Tap refresh to load.')); } return ListView.builder( itemCount: products.length, itemBuilder: (context, index) => ListTile( title: Text(products[index].name), subtitle: Text('\$${products[index].price}'), ), ); } } ``` ## References URLs from the documentation: - https://asyncredux.com/flutter/basics/wait-fail-succeed - https://asyncredux.com/flutter/miscellaneous/advanced-waiting - https://asyncredux.com/flutter/advanced-actions/action-status - https://asyncredux.com/flutter/basics/failed-actions - https://asyncredux.com/flutter/advanced-actions/errors-thrown-by-actions - https://asyncredux.com/flutter/basics/using-the-store-state - https://asyncredux.com/flutter/basics/dispatching-actions - https://asyncredux.com/flutter/basics/async-actions - https://asyncredux.com/flutter/miscellaneous/refresh-indicators ================================================ FILE: .github/copilot-instructions.md ================================================ ================================================ FILE: .github/workflows/test.yaml ================================================ name: Build on: push: branches: - master - develop pull_request: jobs: test: name: Run tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 with: channel: stable - run: flutter pub get - run: flutter test ================================================ FILE: .gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # IntelliJ related *.iml *.ipr *.iws .idea/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ # Flutter/Dart/Pub related **/.flutter-plugins-dependencies **/flutter_export_environment.sh **/doc/api/ .dart_tool/ .flutter-plugins .packages .pub-cache/ .pub/ /build/ build/ ios/.generated/ ios/Flutter/Generated.xcconfig ios/Runner/GeneratedPluginRegistrant.* pubspec.lock *.lock .flutter-plugins-dependencies # Android related **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ **/android/gradlew **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java # iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside **/ios/**/*.pbxuser **/ios/**/*.perspectivev3 **/ios/**/*sync/ **/ios/**/.sconsign.dblite **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Generated.xcconfig **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages ================================================ FILE: .metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled and should not be manually edited. version: revision: b712a172f9694745f50505c93340883493b505e5 channel: stable project_type: package ================================================ FILE: CHANGELOG.md ================================================ _Visit the AsyncRedux App Example GitHub Repo for a full-fledged example app showcasing the fundamentals and best practices._ Sponsored by [MyText.ai](https://mytext.ai) [![](./example/SponsoredByMyTextAi.png)](https://mytext.ai) ## 28.0.0-dev.3 * **DEPRECATION WARNING:** `Store.globalWrapError` and `Store.errorObserver` are now deprecated. Use the new `globalErrorObserver` instead. * You can now provide a _global error observer_ using the `globalWrapError` parameter in the `Store` constructor: ```dart var store = Store( initialState: AppState(), globalErrorObserver: (store) => MyGlobalErrorObserver(), } class MyGlobalErrorObserver extends GlobalErrorObserver { @override void wrap() { // Here you can use: // `error` -> Thrown by the action, AFTER being processed by the action's `wrapError`. // `originalError` -> Error BEFORE being processed by the action's `wrapError`. // `stackTrace` -> The stack trace of the error. // `action` -> The action that threw the error. // `store` -> Use it to read the `store.environment` or `store.configuration`. } } ``` # Use cases 1. Use this to set up your app to use 3rd-party services like Sentry or Firebase Crashlytics to monitor your app for errors in production, and print them to the console in development and testing. Since you are setting it up in a centralized way, you don't have to "pollute" your code with logging calls. 2. Use this to have a global place to convert some exceptions into `UserException`s. For example, Firebase may throw some `PlatformException`s in response to a bad connection to the server. In this case, you may want to show the user a dialog explaining that the connection is bad, which you can do by converting it to a `UserException`. Note, this could also be done in the `ReduxAction.wrapError`, but then you'd have to add it to all actions that use Firebase. * **BREAKING:** Removed the deprecated `Store.wrapError`. Use the new `globalErrorObserver` instead. * **BREAKING:** Removed the deprecated: - `ActionStatus.isBeforeDone` (replace with `hasFinishedMethodBefore`) - `isReduceDone` (replace with `hasFinishedMethodReduce`) - `isAfterDone` (replace with `hasFinishedMethodAfter`) - `isFinished` (replace with `isBeforeDone && isReduceDone && isAfterDone`) ## 27.1.1 * Added `store.removeError(source)` to remove `UserException` errors from the error queue. You can pass it a `UserException`, an `ActionStatus`, or a `ReduxAction`. This is sometimes useful in tests. For example: ```dart // Dispatch some action var status = await store.dispatchAndWait(SomeAction()); // Check the action failed as expected expect(status.originalError, isError('Insufficient balance.')); // Make sure there are no more errors store.removeError(status); expect(store.errors, isEmpty); ``` * `ActionStatus.context` now has a reference to the action and the store. ## 27.0.0 * **BREAKING:** This version is only a breaking change if you are using the `enviroment` parameter of the `Store` constructor to do dependency injection. The `Store` constructor now accepts `dependencies` and `configuration` parameters, in addition to `environment`. See file `main_dependency_injection.dart` in the `example` directory for an example. This provides for very granular dependency injection, for all app needs: - `environment`: Specifies if the app is running in production, staging, development, testing, etc. Should be immutable and not change during app execution. Example: ```dart enum Environment { production, staging, testing; bool get isProduction => this == Environment.production; bool get isStaging => this == Environment.staging; bool get isTesting => this == Environment.testing; } ``` - `dependencies`: A container for injected dependencies (like services, repositories, APIs, etc.), created via a factory that receives the `Store`, so it can vary based on the environment and/or the configuration. Example: ```dart abstract class Dependencies { factory Dependencies(Store store) { if (store.environment == Environment.production) { return DependenciesProduction(); } else if (store.environment == Environment.staging) { return DependenciesStaging(); } else { return DependenciesTesting(); } } } ``` - `configuration`: For feature flags and other configuration values. ```dart class Config { // Add whatever configuration values you need here, if any. bool isABtestingOn = false; bool showAdminConsole = false; ... } ``` - `configuration`: For feature flags and other configuration values. This is how you create a store with these three parameters: ```dart store = Store( initialState: AppState.initial(), environment: Environment.production, dependencies: (store) => Dependencies(store), configuration: (store) => Configuration(store), ); ``` * **BREAKING:** `Store.env` has been renamed to `Store.environment`. * **BREAKING:** Removed `ReduxAction.env`. Access it through `store.environment` instead. It's recommended to define a typed getter in your base action class: ```dart abstract class Action extends ReduxAction { Dependencies get dependencies => super.store.dependencies as Dependencies; Environment get environment => super.store.environment as Environment; Config get config => super.store.configuration as Config; } ``` * **BREAKING:** Removed `VmFactory.env`. Access dependencies through `store.dependencies` instead. Define a typed getter in your base factory class: ```dart abstract class AppFactory extends VmFactory { AppFactory([T? connector]) : super(connector); Dependencies get dependencies => store.dependencies as Dependencies; Environment get environment => store.environment as Environment; Config get config => store.configuration as Config; } ``` * **Final thoughts**: Why is AsyncRedux now providing dependency injection features? The reason is testing. When you create a store in a test, you provide the environment, dependencies, and configuration as parameters. As soon as the test ends, and the store is disposed, the environment, dependencies and configuration are disposed with it. This makes tests less verbose and less prone to memory leaks. ## 26.4.2 * Added the `Polling` mixin and `Poll` enum. Use this mixin to periodically dispatch an action at a fixed interval, keeping data fresh by fetching it from a server. This is useful for refreshing prices, checking for new messages, or monitoring wallet balances. Control polling with the `Poll` enum: `Poll.start` to begin polling (also runs the action immediately), `Poll.stop` to cancel it, `Poll.runNowAndRestart` to run immediately and restart the timer, and `Poll.once` to run immediately without affecting the timer. The default interval is 10 seconds, but you can override `pollInterval`. ```dart class PollPrices extends AppAction with Polling { @override final Poll poll; PollPrices({this.poll = Poll.start}); @override ReduxAction createPollingAction() => PollPrices(); @override Future reduce() async { final prices = await api.getPrices(); return state.copy(prices: prices); } } // Start polling (also runs reduce immediately): dispatch(PollPrices()); // Stop polling: dispatch(PollPrices(poll: Poll.stop)); ``` ### Flexible architecture: You can use a single action for both polling control and work, or separate them into two action types. For example, you could have a `ControlPricePolling` action that only starts/stops the polling, and a separate `FetchPrices` action that does the actual fetching. ## 26.3.3 * Added Claude Code **Skills** to help developers use `async_redux` with AI assistants. See: https://github.com/marcglasberg/async_redux/tree/master/.claude/skills ## 26.2.2 * Improved the `Fresh` mixin. * Improved mixin docs. ## 26.2.1 * Improved the `OptimisticSyncWithPush` mixin. ## 26.2.0 * Added the `OptimisticCommand` mixin. Use this mixin for command-based operations where you want to optimistically update the UI immediately, send a command to the server, and automatically rollback if the server request fails. This is useful for **blocking** user interactions like adding a todo item, deleting a record, or updating user settings, where you want instant UI feedback but also need to ensure consistency with the server. It's blocking in the sense that the user cannot perform other operation in the same state until the command completes (success or failure). See file `example/lib/main_optimistic_command.dart` for an example app demonstrating the use of `OptimisticCommand` in a like button. ```dart class SaveTodo extends AppAction with OptimisticCommand { final Todo newTodo; SaveTodo(this.newTodo); // The new Todo is going to be optimistically applied to the state, right away. @override Object? optimisticValue() => newTodo; // We teach the action how to read the Todo from the state. @override Object? getValueFromState(AppState state) => state.todoList.getById(newTodo.id); // We teach the action how to add the new Todo to the state. @override AppState applyValueToState(AppState state, Object? value) => state.copy(todoList: state.todoList.add(newTodo)); // Contact the server to send the command (save the Todo). I @override Future sendCommandToServer(Object? newTodo) async => await saveTodo(newTodo); // If the server returns a value, we may apply it to the state. @override AppState applyServerResponseToState(AppState state, Todo todo) => state.copy(todoList: state.todoList.add(todo)); // Reload from the cloud (in case of error). @override Future reloadFromServer() async => await loadTodo(); } ``` ### Key features: - **Instant UI update**: The state is updated immediately when the action is dispatched, before the server request completes. - **Automatic rollback**: If `sendCommandToServer` fails, the mixin checks if the current state still contains the optimistic value. If so, it safely rolls back to the initial value. - **Non-reentrant by default**: Concurrent dispatches of the same action type are prevented. Use `nonReentrantKeyParams()` to allow parallel execution for different parameters (e.g., different item IDs). - **Optional reload**: Override `reloadFromServer()` to fetch fresh data from the server after the command completes (success or failure). * Added the `OptimisticSync` mixin. Use this mixin for **non-blocking** user interactions where you want instant UI feedback and automatic synchronization with the server. It's non-blocking in the sense that the user can continue performing other operations in the same state while synchronization is in progress. The mixin handles rapid user interactions gracefully by coalescing requests and ensuring eventual consistency. This is ideal for toggle buttons (like/unlike, follow/unfollow), sliders, switches, or any control where the user might interact multiple times before the server responds. See file `example/lib/main_optimistic_sync.dart` for an example app demonstrating the use of `OptimisticSync` in a like button. ```dart class ToggleLike extends ReduxAction with OptimisticSync { final String itemId; ToggleLike(this.itemId); // Differentiate by item ID so different items can sync independently. Object? optimisticSyncKeyParams() => itemId; // The value to apply optimistically (toggle current state). bool valueToApply() => !state.items[itemId].isLiked; // Apply the value to the state. AppState applyOptimisticValueToState(AppState state, bool isLiked) => state.copyWith(items: state.items.setLiked(itemId, isLiked)); // Get the current value from the state. bool getValueFromState(AppState state) => state.items[itemId].isLiked; // Send the value to the server. Future sendValueToServer(Object? value) async { var response = await api.setLiked(itemId, value as bool); return response.liked; // Return server-confirmed value, or null. } // Apply server response to the state (optional). AppState? applyServerResponseToState(AppState state, Object response) => state.copyWith(items: state.items.setLiked(itemId, response as bool)); } ``` ### How it works: 1. **Optimistic update**: When dispatched, the UI is updated immediately. 2. **Request coalescing**: If the user interacts again while a request is in flight, the new value is applied to the UI but no new request is sent yet. The mixin waits for the current request to complete. 3. **Follow-up requests**: After the request completes, the mixin checks if the state value differs from what was sent. If so, it sends a follow-up request with the latest value. 4. **Server response**: When the state finally stabilizes, the server response is applied (if provided). ### Example scenario: User rapidly clicks a like button: Like → Unlike → Like 1. First click: UI shows "liked", request sent with `true` 2. Second click: UI shows "unliked", no new request yet (one in flight) 3. Third click: UI shows "liked", no new request yet 4. First request completes: Mixin sees state (`true`) matches what was sent (`true`), so no follow-up needed 5. UI remains "liked", server is in sync ### Customization: Override `onFinish()` to run code after synchronization completes: ```dart Future onFinish(Object? error) async { if (error != null) { // Reload from server on error var data = await api.loadItem(itemId); return state.copyWith(items: state.items.update(itemId, data)); } return null; } ``` * Added the `OptimisticSyncWithPush` and `ServerPush` mixins. Use these mixins together when your app receives server-pushed updates (WebSockets, Server-Sent Events, Firebase, etc.) that may modify the same state your actions control. Read the documentation in their own code to understand how they work. **Important:** If your app does NOT receive server-pushed updates, you should use the simpler `OptimisticSync` mixin instead. See file `example/lib/main_optimistic_sync_with_push.dart` for an example app demonstrating the use of `OptimisticSyncWithPush` in a like button. ## 26.1.0 * Updated website documentation in [asyncredux.com](https://asyncredux.com). * Added the `Fresh` mixin. Suppose you want to load from the server the information needed to show a `UserProfileScreen`. You can dispatch action `LoadUserProfile` from the `initState()` method of your widget: ```dart class UserProfileScreen extends StatefulWidget { _UserProfileScreenState createState() => _UserProfileScreenState(); } class _UserProfileScreenState extends State { void initState() { super.initState(); store.dispatch(LoadUserProfile()); // Here! } Widget build(BuildContext context) => ... } ``` Now, add `with Fresh` to the `LoadUserProfile` action, which loads the user profile: ```dart class LoadUserProfile extends AppAction with Fresh { Future reduce() async { var profile = await loadUserProfile(); return state.copy(profile: profile); } } ``` To keep the data fresh for one minute, override `freshFor`, which is in milliseconds. ```dart class LoadUserProfile extends AppAction with Fresh { int freshFor = 60000; // Here! ... } ``` Now, if the user leaves the screen and returns in less than a minute, the profile will not be loaded again, because it is still fresh. If the user returns later, the information is loaded again. ### Another example Suppose widget `UserAvatar` loads its own information when mounted: ```dart class UserAvatar extends StatefulWidget { final String userId; UserAvatar(this.userId); _UserAvatarState createState() => _UserAvatarState(); } class _UserAvatarState extends State { void initState() { super.initState(); store.dispatch(LoadUserAvatar(widget.userId)); // Here! } Widget build(BuildContext context) => ... } ``` Now add `with Fresh` to the `LoadUserAvatar` action, which loads the user avatar, and also override `freshKeyParams()` so that each different user id has its own fresh period: ```dart class LoadUserAvatar extends AppAction with Fresh { final String userId; LoadUserAvatar(this.userId); Object? freshKeyParams() => userId; // Here! Future reduce() async { var avatar = await loadUserAvatar(userId); return state.copy(avatars: {...state.avatars, userId: avatar}); } } ``` Now, if the avatar for a given user is shown more than once in the screen, it will only be loaded for that user once, effectively deduplicating the loading of the same avatar multiple times. ### In more detail The `Fresh` mixin lets you mark the result of an action as "fresh" for a set amount of time. While the result is fresh, repeated dispatches of the same action (or of other actions that share the same fresh key) are skipped because the current state already has valid data. When the fresh period ends, the result becomes "stale" and the next dispatch runs the action again. This is useful for actions that load information from a server. You can think of the fresh period as the time during which the loaded data is still good to use. ### Fresh-keys By default, the fresh-key is based on the action `runtimeType` and the value returned by `freshKeyParams()`. If you need separate fresh periods per id, url, or some other field, override `freshKeyParams()`: ```dart class LoadUserCart extends AppAction with Fresh { final String userId; LoadUserCart(this.userId); // Each different `userId` in action LoadUserCart has its own fresh period. Object? freshKeyParams() => userId; ... } ``` You can also return a tuple if you want the key to depend on more than one field: ```dart // Each different `LoadUserCart`, `userId`, and `cartId` combination has its own fresh period. Object? freshKeyParams() => (userId, cartId); ``` The `Fresh` mixin has many other useful features. See the documentation at [asyncredux.com](https://asyncredux.com) to learn about `ignoreFresh`, `computeFreshKey()`, and more. * Mixins now warn you when you use incompatible mixins together. * Now, you can simply use `dispatch(action)` in widgets, instead of `context.dispatch(action)`. For example: ```dart Widget build(BuildContext context) { return ElevatedButton( onPressed: () { dispatch(MyAction()); // Here! }, child: Text('Press me'), ); } ``` The same applies to the other dispatch extension methods like `dispatchAndWait()`, `dispatchAll()`, `dispatchAndWaitAll()`, and `dispatchSync()`. Note this works only when your app has a single StoreProvider, which is recommended and almost always true. Otherwise, you need to continue using `context.dispatch()` etc. ## 26.0.0 * **BREAKING**: This version requires newer Android tooling (Android Gradle Plugin 8.12.1 or higher, Gradle 8.13 or higher, and Kotlin 2.2.0). Projects using older Android setups must update their environment before upgrading to this release. Workaround: If you want to keep using older Gradle plugins, simply add the following to the dependencies in your `pubspec.yaml` file: `connectivity_plus: ^6.0.0`. * You can now use the new `MockBuildContext` to test **connector widgets** (smart widgets) that rely on `BuildContext` extensions like `context.state`, `context.select()`, `context.dispatch()`, and others. This lets you test both state and callbacks without putting the widget in the widget tree (regular `test` calls, no need to use `testWidgets`). For example: ```dart // Define your smart widget (connector) using context extensions. class MyConnector extends StatelessWidget { @override Widget build(BuildContext context) { return MyWidget( name: context.state.name, onChangeName: () => context.dispatch(ChangeName('Bob')), ); } } class ChangeName extends ReduxAction { final String newName; ChangeName(this.newName); AppState reduce() => state.copy(name: newName); } // Test the connector. test('MyConnector', () { // Create a store with the desired state. var store = Store(initialState: AppState(name: 'John')); // Create a mock context and build the widget. var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Test the widget state. expect(widget.name, 'John'); // Test the widget callbacks. widget.onChangeName(); expect(store.state.name, 'Bob'); }); ``` Note, the dumb widget `MyWidget` is a simple widget that takes `name` and `onChangeName`. You can test it with normal presentation tests (using `testWidgets`) without a store. Just pass the needed values to its constructor. For example: ```dart // Define your dumb widget. class MyWidget extends StatelessWidget { final String name; final VoidCallback onChangeName; const MyWidget({required this.name, required this.onChangeName}); @override Widget build(BuildContext context) { return TextButton(onPressed: onChangeName, child: Text(name)); } } // Test it. testWidgets('MyWidget', (tester) async { bool called = false; await tester.pumpWidget(MaterialApp( home: MyWidget(name: 'John', onChangeName: () => called = true), )); expect(find.text('John'), findsOneWidget); await tester.tap(find.byType(TextButton)); expect(called, true); }); ``` * `StoreConnector` is now considered deprecated. It will **not** be marked as deprecated and will **never** be removed, but you don't need to use it for new code. For new code, when you want to implement the smart/dumb widget pattern, prefer `BuildContext` extensions to implement the pattern, along with `MockBuildContext` for testing, as shown above. The goal of `StoreConnector` was to separate dumb widgets from smart widgets and let you test the view model without mounting it. Then you could test the dumb widget with simple presentation tests. `MockBuildContext` gives you the same benefits, because the dumb widget itself, when built with a mock context, works as the view model you can inspect and use to call callbacks. This makes `StoreConnector` unnecessary. `MockBuildContext` is simpler to use and avoids extra view model classes and factories. ## 25.6.3 * You can now use context extensions to dispatch actions from the `initState()` and `dispose()` methods of a `StatefulWidget`. ```dart class MyScreen extends StatefulWidget { State createState() => _MyScreenState(); } class _MyScreenState extends State { void initState() { super.initState(); context.dispatch(LoadDataAction()); } void dispose() { context.dispatch(CleanupAction()); super.dispose(); } Widget build(BuildContext context) => Text(context.state.data); } ``` Note: For this feature to work, your app must have a single `StoreProvider` (that's usually the case). ## 25.6.2 * You can now use the selector extension `context.select((state) => ...)` to select only the part of the state you need in your widget, so that your widget only rebuilds when that particular part of the state changes. For example: ```dart var myInfo = context.select((state) => state.myInfo); ``` Note you can also access your state directly with `context.state.myInfo`, but that will rebuild your widget whenever **any** part of the state changes. Using `context.select()` is more efficient because it only rebuilds your widget when the selected part of the state changes. Suggestion: When creating the first draft of your widget, you may use `context.state` just to get started quickly, and then later change it to use `context.select()` to optimize the rebuilds. If you want to read your state and NOT rebuild your widget when the state changes, you can use `context.read()`. For example: ```dart var myInfo = context.read().myInfo; ``` However, to use `context.select()`, `context.read()`, and `context.state` as shown above, you need to define the following extension method in your own code (assuming your state class is called `AppState`): ```dart extension BuildContextExtension on BuildContext { R select(R Function(AppState state) selector) => getSelect(selector); } ``` Note, you can also use the other context extension methods like `context.dispatch`, `context.isWaiting`, `context.isFailed`, `context.exceptionFor`, `context.event`, `context.clearExceptionFor`, `context.env`, and much more. See the: Select Example. * You can now use the event extension `context.event((state) => ...)` to consume events from the state. These are one-time notifications used to trigger side effects in widgets, such as showing dialogs, clearing text fields, or navigating to new screens. Unlike regular state values, events are automatically "consumed" (marked as spent) after being read, ensuring they only trigger once. First, define events in your state class and initialize them as spent: ```dart class AppState { final Evt clearTextEvt; final Evt changeTextEvt; AppState({required this.clearTextEvt, required this.changeTextEvt}); static AppState initialState() => AppState( clearTextEvt: Evt.spent(), changeTextEvt: Evt.spent(), ); } ``` Then, your actions create new events by adding them in the state: ```dart // Boolean event. class ClearText extends AppAction { AppState reduce() => state.copy(clearTextEvt: Evt()); } // Event with a String payload. class ChangeText extends AppAction { Future reduce() async { String newText = await fetchTextFromApi(); return state.copy(changeTextEvt: Evt(newText)); } } ``` Finally, use `context.event((state) => ...)` to consume events in the build method of your widgets: ```dart bool clearText = context.event((state) => state.clearTextEvt); if (clearText) controller.clear(); String? newText = context.event((state) => state.changeTextEvt); if (newText != null) controller.text = newText; ``` To use `context.event()` as shown above, you need to define the following extension method in your own code (assuming your state class is called `AppState`): ```dart extension BuildContextExtension on BuildContext { R? event(Evt Function(AppState state) selector) => getEvent(selector); } ``` Important notes: - Events are consumed only once. After consumption, they are marked as " spent" and won't trigger again until a new event is dispatched. - Each event can be consumed by **only one widget**. If you need multiple widgets to react to the same trigger, use separate events in the state. - Initialize events in the state as spent: `Evt.spent()` or `Evt.spent()`. - For events with **no generic type** (`Evt`): Returns **true** if the event was dispatched, or **false** if it was already spent. - For events with **a value type** (`Evt`): Returns the **value** if the event was dispatched, or **null** if it was already spent. See the: Event Example. * You can now use the environment extension `context.env` to access the store "environment" for dependency injection. This environment is a container for injected services that can be accessed from both widgets and actions. First, define your environment interface and implementation: ```dart abstract class Environment { ApiService get api; AuthService get auth; } class EnvironmentImpl implements Environment { final ApiService api = ApiServiceImpl(); final AuthService auth = AuthServiceImpl(); } ``` Then, provide the environment when creating the store: ```dart var store = Store( initialState: AppState.initialState(), environment: EnvironmentImpl(), ); ``` To access the environment in your actions, extend `ReduxAction` to provide typed access: ```dart abstract class Action extends ReduxAction { Environment get env => super.env as Environment; } // Usage class LoadUserAction extends Action { Future reduce() async { var user = await env.api.getUser(); return state.copy(user: user); } } ``` To access the environment in your widgets, define an extension method: ```dart extension BuildContextExtension on BuildContext { Environment get env => getEnvironment() as Environment; } ``` Then use it in your widgets: ```dart Widget build(BuildContext context) { final env = context.env; // Use env.api, env.auth, etc. ... } ``` Benefits of using the environment: - **Dependency Injection**: Inject services, repositories, and other dependencies. - **Testability**: Easily swap implementations for testing (mock services, test APIs, etc.). - **Clean Architecture**: Keep your actions and widgets decoupled from concrete implementations. See the: Environment Example. ## 25.4.0 * Added `Store.disposeProp(key)` and `Action.disposeProp(key)` methods to dispose and remove Futures/Timers/Streams that were previously set using `setProp()`. See [Streams and Timers](https://asyncredux.com/flutter/miscellaneous/streams-and-timers/). ## 25.3.1 * In tests, you can now use `store.dispatchAndWaitAllActions`. It first dispatches an `action`, and then it waits until ALL current actions in progress finish dispatching. In other words, it helps make sure that the app state "settled" before you check the state. ```dart await store.dispatchAndWaitAllActions(MyAction()); ``` * Some internal properties used by the provided mixins are now tied to the `Store` so that they reset when the store is recreated. This is useful to make sure tests are not affected by previous tests. For example, if you dispatch an action that has some throttle, then recreate the store for another test, you can dispatch the same action again without waiting for the throttle to expire. You can also manually delete all those properties by calling `store.internalMixinProps.clear()`. * If you are running tests, you can change `store.forceInternetOnOffSimulation` to simulate the internet connection as ON or OFF for the provided mixins `CheckInternet`, `AbortWhenNoInternet`, and `UnlimitedRetryCheckInternet`: ```dart // There is internet store.forceInternetOnOffSimulation = () => false; // There is no internet store.forceInternetOnOffSimulation = () => false; // Uses the real internet connection status (default). store.forceInternetOnOffSimulation = () => null; ``` ## 25.1.1 * The `Throttle` action mixin now has an `ignoreThrottle` parameter, which allows you to ignore the throttle period for a specific action. This is useful when you want to bypass the throttle for certain actions, while still applying it to others. For example: ```dart class MyAction extends ReduxAction with Throttle { final bool force; MyAction({this.force = false}); bool get ignoreThrottle => force; // Here! ... } ``` * The `Throttle` action mixin now has a `removeLockOnError` parameter, which removes the lock when an error occurs. This is useful when you want to allow a failed action to run again within the throttle period. For example: ```dart class MyAction extends ReduxAction with Throttle { bool removeLockOnError = true; // Here! ... } ``` * If your app uses AsyncRedux and your server uses [Serverpod](https://serverpod.dev/), you can add the Dart-only core package https://pub.dev/packages/async_redux_core to your server side. Now, if you throw a `UserException` in your backend code, that exception will automatically be thrown in the frontend. As long as the Serverpod cloud function is called inside an action, AsyncRedux will display the exception message to the user in a dialog (or other UI element that you can customize). Note: This can also be used with [package i18n_extension_core](https://pub.dartlang.org/packages/i18n_extension_core) to make sure the error message gets translated to the user's language. For example: `UserException('The password you typed is invalid'.i18n);` in the backend, will reach the frontend already translated as `UserException('La contraseña que ingresaste no es válida')` if the user device is in Spanish. Setup: For all this to work in Serverpod, after you import `async_redux_core` in the `pubspec.yaml` file of the server project, you must add the `UserException` class to your `generator.yaml` file, in its `extraClasses` section: ```yaml type: server ... extraClasses: - package:async_redux_core/async_redux_core.dart:UserException ``` Note: AsyncRedux also works with [Celest](https://celest.dev/) since 22.1.0. ## 25.0.0 * **BREAKING**: The action's `wrapReduce` method now returns `FutureOr` instead of returning `FutureOr Function()` This breaking change is unlikely to affect you in any way, because the `wrapReduce` is an advanced feature mostly used to implement action mixins, like `Retry` and `Debounce`. * You can now use the `Debounce` action mixin. Debouncing delays the execution of a function until after a certain period of inactivity. Each time the debounced function is called, the period of inactivity (or wait time) is reset. The function will only execute after it stops being called for the duration of the wait time. Debouncing is useful in situations where you want to ensure that a function is not called too frequently and only runs after some “quiet time.” For example, it’s commonly used for handling input validation in text fields, where you might not want to validate the input every time the user presses a key, but rather after they've stopped typing for a certain amount of time. The `debounce` value is given in milliseconds, and the default is 333 milliseconds (1/3 of a second). You can override this default: ```dart class MyAction extends ReduxAction with Debounce { final int debounce = 1000; // Here! ... } ``` ### Advanced debounce usage The debounce is, by default, based on the action `runtimeType`. This means it will reset the debounce period when another action of the same runtimeType was is dispatched within the debounce period. In other words, the runtimeType is the "lock". If you want to debounce based on a different lock, you can override the `lockBuilder` method. For example, here we debounce two different actions based on the same lock: ```dart class MyAction1 extends ReduxAction with Debounce { Object? lockBuilder() => 'myLock'; ... } class MyAction2 extends ReduxAction with Debounce { Object? lockBuilder() => 'myLock'; ... } ``` Another example is to debounce based on some field of the action: ```dart class MyAction extends ReduxAction with Debounce { final String lock; MyAction(this.lock); Object? lockBuilder() => lock; ... } ``` See the [Documentation](https://asyncredux.com/flutter/advanced-actions/action-mixins#debounce). * You can now use the `Throttle` action mixin. Throttling ensures the action will be dispatched at most once in the specified throttle period. In other words, it prevents the action from running too frequently. If an action is dispatched multiple times within a throttle period, it will only execute the first time, and the others will be aborted. After the throttle period has passed, the action will be allowed to execute again, which will reset the throttle period. If you use the action to load information, the throttle period may be considered as the time the loaded information is "fresh". After the throttle period, the information is considered "stale" and the action will be allowed to load the information again. For example, if you are using a `StatefulWidget` that needs to load some information, you can dispatch the loading action when widget is created, and specify a throttle period so that it doesn't load the information again too often. If you are using a `StoreConnector`, you can use the `onInit` parameter: ```dart class MyScreenConnector extends StatelessWidget { Widget build(BuildContext context) => StoreConnector( vm: () => _Factory(), onInit: _onInit, // Here! builder: (context, vm) { return MyScreenConnector( information: vm.information, ... ), ); void _onInit(Store store) { store.dispatch(LoadAction()); } } ``` and then: ```dart class LoadAction extends ReduxAction with Throttle { final int throttle = 5000; Future reduce() async { var information = await loadInformation(); return state.copy(information: information); } } ``` The `throttle` is given in milliseconds, and the default is 1000 milliseconds (1 second). You can override this default: ```dart class MyAction extends ReduxAction with Throttle { final int throttle = 500; // Here! ... } ``` ### Advanced throttle usage The throttle is, by default, based on the action `runtimeType`. This means it will throttle an action if another action of the same runtimeType was previously dispatched within the throttle period. In other words, the runtimeType is the "lock". If you want to throttle based on a different lock, you can override the `lockBuilder` method. For example, here we throttle two different actions based on the same lock: ```dart class MyAction1 extends ReduxAction with Throttle { Object? lockBuilder() => 'myLock'; ... } class MyAction2 extends ReduxAction with Throttle { Object? lockBuilder() => 'myLock'; ... } ``` Another example is to throttle based on some field of the action: ```dart class MyAction extends ReduxAction with Throttle { final String lock; MyAction(this.lock); Object? lockBuilder() => lock; ... } ``` See the [Documentation](https://asyncredux.com/flutter/advanced-actions/action-mixins#throttle). ## 24.0.7 * Added some missing params to `MockStore` constructor. ## 24.0.6 * Fixed translation typo bug. ## 24.0.2 * `LocalPersist` and `LocalJsonPersist` now allow you to define the base directory by setting the `useBaseDirectory` static field. The default is, as before, the application's documents directory. Other options are the cache directory (`LocalPersist.useAppCacheDir`), the downloads directory (`LocalPersist.useAppDownloadsDir`), or any other custom directory (`LocalPersist.useCustomBaseDirectory`). ## 23.2.0 * You can now use the `UnlimitedRetryCheckInternet` to check if there is internet when you run some action that needs it. If there is no internet, the action will abort silently and then retried unlimited times, until there is internet. It will also retry if there is internet but the action failed. * You can provide a `CloudSync` object to the store constructor. It's similar to the `Persistor`, but can be used to synchronize the state of the application with the server. This is experimental. * Fixed `isWaiting()` for checking multiple actions and when state doesn't change. ## 23.1.1 * New: AsyncRedux website at https://asyncredux.com * New: [AsyncRedux for React](https://www.npmjs.com/package/async-redux-react) ## 23.0.2 * Fixed `isWaiting()` when action fails. ## 23.0.1 * Fixed `disposeProps`. ## 23.0.0 * Now using `connectivity_plus: 6.0.3` or up. ## 22.5.0 * You can now use `dispatchAll()` and `dispatchAndWaitAll()` to dispatch multiple actions in parallel. For example: ```dart class BuyAndSell extends Action { Future reduce() async { await dispatchAndWaitAll([ BuyAction('IBM'), SellAction('TSLA') ]); return state.copy(message: 'New cash balance is ${state.cash}'); } } ``` ## 22.4.9 * For those who use `flutter_hooks`, you can now use the new https://pub.dev/packages/flutter_hooks_async_redux package to add Redux to flutter_hooks. ## 22.3.0 * In the `reduce` method of your actions you can now access the _initial state_ of the action, by using the `initialState` getter. In other words, you have access to a copy of the state as it was when the action was first dispatched. This is useful when you need to calculate some value asynchronously, and then you only want to apply the result to the state if that value hasn't changed in the meantime. For example: ```dart class MyAction extends ReduxAction { Future reduce() async { var newValue = await someAsyncStuff(); if (state.value == initialState.value) return state.copyWith(value: newValue); else return null; } } ``` ## 22.1.0 * You can now use `var isWaiting = context.isWaiting(MyAction)` to check if an async action of the given type is currently being processed. You can then use this boolean to show a loading spinner in your widget. Note: Inside your `VmFactory` you can also use `isWaiting: isWaiting(MyAction)`. See the Show Spinner Example. * You can now use `var isFailed = context.isFailed(MyAction)` to check if an action of the given type has thrown an `UserException`. You can then use this boolean to show an error message. You can also get the exception with `var exception = context.exceptionFor(MyAction)` to use its error message, and clear the exception with `context.clearExceptionFor(MyAction)`. Note: Inside your `VmFactory` you can also use `isFailed: isFailed(MyAction)` etc. See the Show Error Dialog Example. * You can add **mixins** to your actions, to accomplish common tasks: - `CheckInternet` ensures actions only run with internet, otherwise an error dialog prompts users to check their connection: ```dart class LoadText extends ReduxAction with CheckInternet { Future reduce() async { var response = await http.get('http://numbersapi.com/42'); ... }} ``` - `NoDialog` can be added to `CheckInternet` so that no dialog is opened. Instead, you can display some information in your widgets: ```dart class LoadText extends Action with CheckInternet, NoDialog { ... } if (context.isFailed(LoadText)) Text('No Internet connection'); ``` - `AbortWhenNoInternet` aborts the action silently (without showing any dialogs) if there is no internet connection. - `NonReentrant` prevents reentrant actions, so that when you dispatch an action that's already running it gets aborted (no errors are shown). - `Retry` retries the action a few times with exponential backoff, if it fails. Add `UnlimitedRetries` to retry the action indefinitely: ```dart class LoadText extends ReduxAction with Retry, UnlimitedRetries, NonReentrant { ``` Other mixins will be provided in the future, for Throttling, Debouncing and Caching. * Some features of the `async_redux` package are now available in a standalone Dart-only core package: https://pub.dev/packages/async_redux_core. You may use that core package when you are developing a Dart server (backend) with [Celest](https://celest.dev/), or when developing your own Dart-only package that does not depend on Flutter. Note: For the moment, the core package simply contains the `UserException`, and nothing else. If you now import `async_redux_core` in your Celest server code and throw an `UserException` there, the exception message will automatically be shown in a dialog to the user in your client app (if you use the `UserExceptionDialog` feature). > **For Flutter applications nothing changes.** > You don't need to import the core package directly. > You should continue to use this async_redux package, which already exports > the code that's now in the core package. * You can now access the store inside of widgets, and have your widgets rebuild when the state changes, by using `context.state` and `context.dispatch` etc. This is only useful when you want to access the store state, and dispatch actions directly inside your widgets, instead of using the `StoreConnector` ( dumb widget / smart widget pattern). For example: ```dart // Read state (will rebuild when the state changes) var myInfo = context.state.myInfo; // Dispatch action context.dispatch(MyAction()); // Use isWaiting to show a spinner if (context.isWaiting(MyAction)) return CircularProgressIndicator(); // Use isFailed to show an error message if (context.isFailed(MyAction)) return Text('Loading failed'); // Use exceptionFor to get the error message from the exception if (context.isFailed(MyAction)) return Text(context.exceptionFor(MyAction).message); // Use clearExceptionFor to clear the error context.clearExceptionFor(MyAction); ``` However, to use `context.state` as shown above, you need to define the following extension method in your own code (assuming your state class is called `AppState`): ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); } ``` See the: Connector vs Provider Example. * You can now get and set properties in the `Store` using the `prop` and `setProp` methods. These methods are available in `Store`, in `ReduxAction`, and in `VmFactory`. They can be used to save global values, but scoped to the store. For example, you could save timers, streams or futures used by actions: ```dart setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); var timer = prop("timer"); timer.cancel(); ``` You can later use `store.disposeProps` to stop, close or ignore, all stream related objects, timers and futures, saved as props in the store. It will also remove them from there. ## 22.0.0 * **BREAKING**: `StoreConnector.model` was removed, after being deprecated for a long time. Please, use the `vm` parameter instead. See classes `VmFactory` and `Vm`. * **BREAKING**: `ReduxAction.reduceWithState()` was removed, after being deprecated for a long time. * **BREAKING**: `StoreProvider.of` was removed. See `context.state` and `context.dispatch` etc, in version 22.1.0 above. * **BREAKING**: The `UserException` class was modified so that it was possible to move it to the `async_redux_core`. If your use of `UserException` was limited to specifying the error message, then you don't need to change anything: `throw UserException('Error message')` will continue to work as before. However, for other more advanced features you will have to read the `UserException` documentation and adapt. In the new public API of `UserException` you can now specify a `message`, `reason`, `code`, `errorText` and `ifOpenDialog` in the constructor, and then you can use methods `addCallbacks`, `addCause`, `addProps`, `withErrorText` and `noDialog` to add more information: ```dart throw UserException('Invalid number', reason: 'Must be less than 42') .addCallbacks(onOk: () => print('OK'), onCancel: () => print('CANCEL')) .addCause(FormatException('Invalid input')) .addProps({'number': 42})) .withErrorText('Type a smaller number') .noDialog; ``` Note the `code` parameter can only be a number now. If you were using a different type, for example enums, you can now include it in the props, like so: `throw UserException('').addProps({'code': myError.invalidInput}).` or you can even create an extension method which allows you to write `throw UserException('').withCode(myError.invalidInput).` However, please read the new `UserException` documentation to learn about the recommended way to use `code` to define the text of the error messages, and even easily translate them to the user language by using the [i18n_extension](https://pub.dev/packages/i18n_extension) translations package. * To test the view-model generated by a `VmFactory`, you can now use the static method `Vm.createFrom(store, factory)`. The method will return the view-model, which you can use to inspect the view-model properties directly, or call any of the view-model callbacks. Example: ```dart var store = Store(initialState: User("Mary")); var vm = Vm.createFrom(store, MyFactory()); // Checking a view-model property. expect(vm.user.name, "Mary"); // Calling a view-model callback and waiting for the action to finish. vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). await store.waitActionType(SetNameAction); expect(store.state.name, "Bill"); // Calling a view-model callback and waiting for the state to change. vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). await store.waitCondition((state) => state.name == "Bill"); expect(store.state.name, "Bill"); ``` * DEPRECATION WARNING: While the `StoreTester` is a powerful tool with advanced features that are beneficial for the most complex testing scenarios, for **almost all tests** it's now recommended to use the `Store` directly. This approach involves waiting for an action to complete its dispatch process or for the store state to meet a certain condition. After this, you can verify the current state or action using the new methods `store.dispatchAndWait`, `store.waitCondition`, `store.waitActionCondition`, `store.waitAllActions`, `store.waitActionType`, `store.waitAllActionTypes`, and `store.waitAnyActionTypeFinishes`. For example: ```dart // Wait for some action to dispatch and check the state. await store.dispatchAndWait(MyAction()); expect(store.state.name, 'John') // Wait for some action to dispatch, and check for errors in the action status. var status = await dispatchAndWait(MyAction()); expect(status.originalError, isA()); // Dispatches two actions in SERIES (one after the other). await dispatchAndWait(SomeAsyncAction()); await dispatchAndWait(AnotherAsyncAction()); // Dispatches two actions in PARALLEL and wait for their TYPES. expect(store.state.portfolio, ['TSLA']); dispatch(BuyAction('IBM')); dispatch(SellAction('TSLA')); await store.waitAllActionTypes([BuyAction, SellAction]); expect(store.state.portfolio, ['IBM']); // Dispatches two actions in PARALLEL and wait for them. let action1 = BuyAction('IBM'); let action2 = BuyAction('TSLA'); dispatch(action1); dispatch(action2); await store.waitAllActions([action1, action2]); expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); // Wait until no actions are in progress. dispatch(BuyStock('IBM')); dispatch(BuyStock('TSLA')); await waitAllActions([]); expect(state.stocks, ['IBM', 'TSLA']); // Wait for some action of a given type. dispatch(ChangeNameAction()); var action = store.waitActionType(ChangeNameAction); expect(action, isA()); expect(action.status.isCompleteOk, isTrue); expect(store.state.name, 'Bill'); // Wait until any action of the given types finishes dispatching. dispatch(BuyOrSellAction()); var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); expect(store.state.portfolio.contains('IBM'), isTrue); // Wait for some state condition. expect(store.state.name, 'John') dispatch(ChangeNameAction("Bill")); var action = await store.waitCondition((state) => state.name == "Bill"); expect(action, isA()); expect(store.state.name, 'Bill'); ``` Note the `StoreTester` will NOT be removed, now or in the future. It's just not the recommended way to test the store anymore. ## 21.7.1 * DEPRECATION WARNING: - Replace `action.isFinished` with `action.status.isCompletedOk` - Replace `action.status.isBeforeDone` with `action.status.hasFinishedMethodBefore` - Replace `action.status.isReduceDone` with `action.status.hasFinishedMethodReduce` - Replace `action.status.isAfterDone` with `action.status.hasFinishedMethodAfter` - Replace `action.status.isFinished` with `action.status.isCompletedOk` * The `action.status` now has a few more values: - `isCompleted` if the action has completed executing, either with or without errors. - `isCompletedOk` if the action has completed with no errors. - `isCompletedFailed` if the action has completed with errors. - `originalError` Holds the error thrown by the action's before/reduce methods, if any. - `wrappedError` Holds the error thrown by the action, after it was processed by the action's `wrapError` and the `globalWrapError`. ## 21.6.0 * DEPRECATION WARNING: The `wrapError` parameter of the `Store` constructor is now deprecated in favor of the `globalWrapError` parameter. The reason for this deprecation is that the new `GlobalWrapError` works in the same way as the action's `ReduxAction.wrapError`, while `WrapError` does not. The difference is that when `WrapError` returns `null`, the original error is not modified, while with `GlobalWrapError` returning `null` will instead disable the error. In other words, where your old `WrapError` returned `null`, your new `GlobalWrapError` should return the original `error`: ``` // WrapError (deprecated): Object? wrap(error, stackTrace, action) { if (error is MyException) return null; // Keep the error unaltered. else return processError(error); } // GlobalWrapError: Object? wrap( error, stackTrace, action) { if (error is MyException) return error; // Keep the error unaltered. else return processError(error); } ``` Also note, `GlobalWrapError` is more powerful because it can disable the error, whereas `WrapError` cannot. * Throwing an error in the action's `wrapError` or in the `GlobalWrapError` was disallowed (you needed to make sure it never happened). Now, it's allowed. If instead of RETURNING an error you THROW an error inside these wrappers, AsyncRedux will catch it and use it instead the original error. In other words, returning an error or throwing an error from inside the wrappers now has the same effect. However, it is still recommended to return the error rather than throwing it. ## 21.5.0 * DEPRECATION WARNING: Method `dispatchAsync` was renamed to `dispatchAndWait`. The old name is still available, but deprecated and will be removed. The new name is more descriptive of what the method does, and the fact that `dispatchAndWait` can be used to dispatch both sync and async actions. The only difference between `dispatchAndWait` and `dispatch` is that `dispatchAndWait` returns a `Future` which can be awaited to know when the action is finished. ## 21.1.1 * `await StoreTester.dispatchAndWait(action)` dispatches an action, and then waits until it finishes. This is the same as doing: `storeTester.dispatch(action); await storeTester.wait(action);`. ## 21.0.2 * Flutter 3.16.0 compatible. ## 20.0.2 * Fixed `WrapReduce` (which may be used to wrap the reducer to allow for some pre- or post-processing) to avoid async reducers to be called twice. ## 20.0.0 * Flutter 3.10.0 and Dart 3.0.0 ## 19.0.2 * Docs improvement. ## 19.0.1 * Flutter 3.7.5, Dart 2.19.2, fast_immutable_collections: 9.0.0. * **BREAKING**: The `Action.wrapError(error, stackTrace)` method now also gets the stacktrace instead of just the error. If your code breaks, just add the extra parameter, like so: `Object wrapError(error) => ...` turns into `Object wrapError(error, _) => ...`
* **BREAKING**: When a `Persistor` is provided to the Store, it now considers the `initialState` is already persisted. Before this change, it considered nothing was persisted. Note: Before you create the store, you are allowed to call the `Persistor` methods directly: `Persistor.saveInitialState()`, `readState()` and `deleteState()`. However, after you create the store, please don't call those methods yourself anymore. If you do it, AsyncRedux cannot keep track of which state was persisted. After store creation, if necessary, you should use the corresponding methods `Store.saveInitialStateInPersistence()`, `Store.readStateFromPersistence()` and `Store.deleteStateFromPersistence()`. These methods let AsyncRedux keep track of the persisted state, so that it's able to call `Persistor.persistDifference()` with the appropriate parameters.
* Method `Store.getLastPersistedStateFromPersistor()` returns the state that was last persisted to the local persistence. It's unlikely you will use this method yourself.
* **BREAKING**: The factory declaration used to have two type parameters, but now it has three: `class Factory extends VmFactory` With that change, you can now reference the view-model inside the Factory methods, by using the `vm` getter. Example: ``` ViewModel fromStore() => ViewModel( value: _calculateValue(), onTap: _onTap); } void _onTap() => dispatch(SaveValueAction(vm.value)); // Use the value from the vm. ``` Note 1: You can only use the `vm` getter after the `fromStore()` method is called, which means you cannot reference the `vm` inside of the `fromStore()` method itself. If you do that, you'll get a `StoreException`. You also cannot use the `vm` getter if the view-model is null. Note 2: To reduce boilerplate, and not having to pass the `AppState` type parameter whenever you create a Factory, I recommend you define a base Factory, like so: ``` abstract class BaseFactory extends VmFactory { BaseFactory([T? connector]) : super(connector); } ``` * Added class LocalJsonPersist to help persist the state as pure Json. ## 18.0.2 * Fixed small bug when persistor is paused before being used once. ## 18.0.0 * Version bump of dependencies. ## 17.0.1 * Fixed issue with the StoreConnector.shouldUpdateModel method when the widget updates. ## 17.0.0 * The `StateObserver.observe()` method signature changed to include an `error` parameter: ``` void observe( ReduxAction action, St stateIni, St stateEnd, Object? error, int dispatchCount, ); ``` The state-observers are now also called when the action reducer complete with a error. In this case, the `error` object will not be null. This makes it easier to use state-observers for metrics. Please, see the documentation for the recommended clean-code way to do this. ## 16.1.0 * Added another cache function, for 2 states and 3 parameters: `cache2states_3params`. ## 16.0.0 * **BREAKING**: Async `reduce()` methods (those that return Futures) are now called synchronously (in the same microtask of their dispatch), just like a regular async function is. In other words, now dispatching a sync action works just the same as calling a sync function, and dispatching an async action works just the same as calling an async function. ``` // Example: The below code will print: "BEFORE a1 f1 AFTER a2 f2" print('BEFORE'); dispatch(MyAsyncAction()); asyncFunction(); print('AFTER'); class MyAsyncAction extends ReduxAction { Future reduce() async { print('a1'); await microtask; print('a2'); return state; } } Future asyncFunction() async { print('f1'); await Future.microtask((){}); print('f2'); } ``` Before version `16.0.0`, the `reduce()` method was called in a later microtask. Please note, the async `reduce()` methods continue to return and apply the state in a later microtask ( this did not change). The above breaking change is unlikely to affect you in any way, but if you want the old behavior, just add `await microtask;` to the first line of your `reduce()` method.
* **BREAKING**: When your reducer is async (i.e., returns `Future`) you must make sure you **do not return a completed future**, meaning all execution paths of the reducer must pass through at least one `await` keyword. In other words, don't return a Future if you don't need it. If your reducer has no `await`s, you must return `AppState?` instead of `Future`, or add `await microtask;` to the start of your reducer, or return `null`. For example: ```dart // These are right: AppState? reduce() { return state; } AppState? reduce() { someFunc(); return state; } Future reduce() async { await someFuture(); return state; } Future reduce() async { await microtask; return state; } Future reduce() async { if (state.someBool) return await calculation(); return null; } // But these are wrong: Future reduce() async { return state; } Future reduce() async { someFunc(); return state; } Future reduce() async { if (state.someBool) return await calculation(); return state; } ``` If you don't follow this rule, AsyncRedux may seem to work ok, but will eventually misbehave. It's generally easy to make sure you are not returning a completed future. In the rare case your reducer function is very complex, and you are unsure that all code paths pass through an `await`, just add `assertUncompletedFuture();` at the very END of your `reduce` method, right before the `return`. If you do that, an error will be shown in the console if the `reduce` method ever returns a completed future. If you're an advanced user interested in the details, check the sync/async tests.
* When the `Event` class was created, Flutter did not have another class with that name. Now there is. For this reason, a typedef now allows you to use `Evt` instead. If you need, you can hide one of them, by importing AsyncRedux like this: ```dart import 'package:async_redux/async_redux.dart' hide Event; ``` or ```dart import 'package:async_redux/async_redux.dart' hide Evt; ``` ## 15.0.0 * Flutter 3.0 support. ## 14.1.4 * `NavigateAction.popUntilRouteName()` can print the routes (for debugging). ## 14.1.2 * Better stacktrace for wrapped errors in actions. ## 14.1.1 * The store persistor can now be paused and resumed, with methods `store.pausePersistor()`, `store.persistAndPausePersistor()` and `store.resumePersistor()`. This may be used together with the app lifecycle, to prevent a persistence process to start when the app is being shut down. For example: ``` child: StoreProvider( store: store, child: AppLifecycleManager( // Add this widget here to capture lifecycle events. child: MaterialApp( ... class AppLifecycleManager extends StatefulWidget { final Widget child; const AppLifecycleManager({Key? key, required this.child}) : super(key: key); _AppLifecycleManagerState createState() => _AppLifecycleManagerState(); } class _AppLifecycleManagerState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } void didChangeAppLifecycleState(AppLifecycleState lifecycle) { store.dispatch(ProcessLifecycleChange_Action(lifecycle)); } Widget build(BuildContext context) => widget.child; } class ProcessLifecycleChangeAction extends ReduxAction { final AppLifecycleState lifecycle; ProcessLifecycleChangeAction(this.lifecycle); Future reduce() async { if (lifecycle == AppLifecycleState.resumed || lifecycle == AppLifecycleState.inactive) { store.resumePersistor(); } else if (lifecycle == AppLifecycleState.paused || lifecycle == AppLifecycleState.detached) { store.persistAndPausePersistor(); } else throw AssertionError(lifecycle); return null; } } ``` * When logging out of the app, you can call `store.deletePersistedState()` to ask the persistor to delete the state from disk. * **BREAKING**: This is a very minor change, unlikely to affect you. The signature for the `Action.wrapError` method has changed from `Object? wrapError(error)` to `Object? wrapError(Object error)`. If you get an error when you upgrade, you can fix it by changing the method that broke into `Object? wrapError(dynamic error)`. * **BREAKING**: Context is now nullable for these StoreConnector methods: ``` void onInitialBuildCallback(BuildContext? context, Store store, Model viewModel); void onDidChangeCallback(BuildContext? context, Store store, Model viewModel); void onWillChangeCallback(BuildContext? context, Store store, Model previousVm, Model newVm); ``` ## 13.3.1 * Version bump of dependencies. ## 13.2.2 * Version bump of dependencies. ## 13.2.1 * Fixed `MockStore.dispatchAsync()` and `MockStore.dispatchSync()` methods. Note: `dispatchAsync` was later renamed to `dispatchAndWait`. ## 13.2.0 * `delay` parameter for `WaitAction.add()` and `WaitAction.remove()` methods. ## 13.1.0 * Added missing `dispatchSync` and `dispatchAsync` to `StoreTester`. Note: `dispatchAsync` was later renamed to `dispatchAndWait`. ## 13.0.6 * Added missing `dispatchSync` to `VmFactory`. ## 13.0.5 * Sometimes, the store state is such that it's not possible to create a view-model. In those cases, the `fromStore()` method in the `Factory` can now return a `null` view-model. In that case, the `builder()` method in the `StoreConnector` can detect that the view-model is `null`, and then return some widget that does not depend on the view-model. For example: ``` return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel? vm) { return (vm == null) ? Text("The user is not logged in") : MyHomePage(user: vm.user) ... class Factory extends VmFactory { ViewModel? fromStore() { return (store.state.user == null) ? null : ViewModel(user: store.state.user) ... class ViewModel extends Vm { final User user; ViewModel({required this.user}) : super(equals: [user]); ``` ## 13.0.4 * `dispatch` can be used to dispatch both sync and async actions. It returns a `FutureOr`. You can await the result or not, as desired. * `dispatchAsync` can also be used to dispatch both sync and async actions. But it always returns a `Future` (not a `FutureOr`). Use this only when you explicitly need a `Future`, for example, when working with the `RefreshIndicator` widget. Note: `dispatchAsync` was later renamed to `dispatchAndWait`. * `dispatchSync` allows you to dispatch SYNC actions only. In that case, `dispatchSync(action)` is exactly the same as `dispatch(action)`. However, if your action is ASYNC, `dispatchSync` will throw an error. Use this only when you need to make sure an action is sync ( meaning it impacts the store state immediately when it returns). This is not very common. Important: An action is sync if and only if both its `before` and `reduce` methods are sync. If any or both these methods return a Future, then the action is async and will throw an error when used with `dispatchSync`. * `StoreTester.getConnectorTester` helps test `StoreConnector`s methods, such as `onInit`, `onDispose` and `onWillChange`. For example, suppose you have a `StoreConnector` which dispatches `SomeAction` on its `onInit`. You could test it like this: ``` class MyConnector extends StatelessWidget { Widget build(BuildContext context) => StoreConnector( vm: () => _Factory(), onInit: _onInit, builder: (context, vm) { ... } } void _onInit(Store store) => store.dispatch(SomeAction()); } var storeTester = StoreTester(...); var connectorTester = storeTester.getConnectorTester(MyConnector()); connectorTester.runOnInit(); var info = await tester.waitUntil(SomeAction); ``` For more information, see section **Testing the StoreConnector** in the README.md file. * Fix: `UserExceptionDialog` now shows all `UserException`s. It was discarding some of them under some circumstances, in a regression created in version 4.0.4. * In the `Store` constructor you can now set `maxErrorsQueued` to control the maximum number of errors the `UserExceptionDialog` error-queue can hold. Default is `10`. * `ConsoleActionObserver` is now provided to print action details to the console. * `WaitAction.toString()` now returns a better description. ## 12.0.4 * `NavigateAction.toString()` now returns a better description, like `Action NavigateAction.pop()`. * Fixed `NavigateAction.popUntilRouteName` and `NavigateAction.pushNamedAndRemoveAll` to return the correct `.type`. * Added section `Dependency Injection` in README.md. ## 12.0.3 * Improved error messages when the reducer returns an invalid type. * New `StoreTester` methods: `waitUntilAll()` and `waitUntilAllGetLast()`. * Passing an environment to the store, to help with dependency injection: `Store(environment: ...)` ## 12.0.0 * **BREAKING**: Improved state typing for some `Store` parameters. You will now have to use `Persistor` instead of `Persistor`, and `WrapError` instead of `WrapError` etc. * Global `Store(wrapReduce: ...)`. You may now globally wrap the reducer to allow for some pre or post-processing. Note: if the action also have a wrapReduce method, this global wrapper will be called AFTER (it will wrap the action's wrapper which wraps the action's reducer). * Downgraded dev_dependencies `test: ^1.16.0` ## 11.0.1 * You can now provide callbacks `onOk` and `onCancel` to an `UserException`. This allows you to dispatch actions when the user dismisses the error dialog. When using the default `UserExceptionDialog`: (i) if only `onOk` is provided, it will be called when the dialog is dismissed, no matter how. (ii) If both `onOk` and `onCancel` are provided, then `onOk` will be called only when the OK button is pressed, while `onCancel` will be called when the dialog is dismissed by any other means. ## 11.0.0 * **BREAKING**: The `dispatchFuture` function is not necessary anymore. Just rename it to `dispatch`, since now the `dispatch` function always returns a future, and you can await it or not, as desired. * **BREAKING**: `ReduxAction.hasFinished()` has been deprecated. It should be renamed to `isFinished`. * The `dispatch` function now returns an `ActionStatus`. Usually you will discard this info, but you may use it to know if the action completed with no errors. For example, suppose a `SaveAction` looks like this: ``` class SaveAction extends ReduxAction { Future reduce() async { bool isSaved = await saveMyInfo(); if (!isSaved) throw UserException("Save failed."); ... } } ``` Then, when you save some info, you want to leave the current screen if and only if the save process succeeded: ``` var status = await dispatch(SaveAction(info)); if (status.isFinished) dispatch(NavigateAction.pop()); // Or: Navigator.pop(context) ``` ## 10.0.1 * **BREAKING**: The new `UserExceptionDialog.useLocalContext` parameter now allows the `UserExceptionDialog` to be put in the `builder` parameter of the `MaterialApp` widget. Even if you use this dialog, it is unlikely this will be a breaking change for you. But if it is, and your error dialog now has problems, simply make `useLocalContext: true` to return to the old behavior. * **BREAKING**: `StoreConnector` parameters `onInitialBuild`, `onDidChange` and `onWillChange` now also get the context and the store. For example, where you previously had `onInitialBuild(vm) {...}` now you have `onInitialBuild(context, store, vm) {...}`. ## 9.0.9 * LocalPersist `saveJson()` and `loadJson()` methods. ## 9.0.8 * FIC and weak-map version bump. ## 9.0.7 * NNBD improvements. * FIC version bump. ## 9.0.1 * Downgrade to file: ^6.0.0 to improve compatibility. ## 9.0.0 * Nullsafe. ## 8.0.0 * Uses nullsafe dependencies (it's not yet itself nullsafe). * **BREAKING**: Cache functions (for memoization) have been renamed and extended. ## 7.0.2 * LocalPersist: Better handling of mock file-systems. ## 7.0.1 * **BREAKING**: Now the `vm` parameter in the `StoreConnector` is a function that creates a `VmFactory` (instead of being a `VmFactory` object itself). So, to upgrade, you just need to provide this: ``` vm: () => MyFactory(this), ``` Instead of this: ``` // Deprecated. vm: MyFactory(this), ``` Now the `StoreConnector` will create a `VmFactory` every time it needs a view-model. The Factory will have access to: 1) `state` getter: The state the store was holding when the factory and the view-model were created. This state is final inside the factory. 2) `currentState()` method: The current (most recent) store state. This will return the current state the store holds at the time the method is called. * New store parameter `immutableCollectionEquality` lets you override the equality used for immutable collections from the fast_immutable_collections package. ## 6.0.3 * StoreTester.dispatchState(). ## 6.0.2 * VmFactory.getAndRemoveFirstError(). ## 6.0.1 * `NavigateAction` now closely follows the `Navigator` api: `push()`, `pop()`, `popAndPushNamed()`, `pushNamed()`, `pushReplacement()`, `pushAndRemoveUntil()`, `replace()`, `replaceRouteBelow()`, `pushReplacementNamed()`, `pushNamedAndRemoveUntil()`, `pushNamedAndRemoveAll()`, `popUntil()`, `removeRoute()`, `removeRouteBelow()`, `popUntilRouteName()` and `popUntilRoute()`. ## 1.0.0 * Initial commit: 2019/Aug05 ================================================ FILE: LICENSE ================================================ Async_redux Package License (05 Aug 2019): https://github.com/marcglasberg/async_redux/blob/master/LICENSE MIT License Copyright (c) 2019 Marcelo Glasberg Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ******************************************************************************* Third-Party licenses of software used in AsyncRedux: ******************************************************************************* Redux Package License (05 Aug 2019): https://github.com/johnpryan/redux.dart/blob/master/LICENSE MIT License Copyright (c) 2016 John Ryan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ******************************************************************************* Flutter_redux Package License (05 Aug 2019): https://github.com/brianegan/flutter_redux/blob/master/LICENSE The MIT License (MIT) Copyright (c) 2017 Brian Egan Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ******************************************************************************* Equatable Package License (05 Aug 2019): https://github.com/felangel/equatable/blob/master/LICENSE MIT License Copyright (c) 2018 Felix Angelov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ******************************************************************************* ================================================ FILE: README.md ================================================ [![Pub Version](https://img.shields.io/pub/v/async_redux?style=flat-square&logo=dart)](https://pub.dev/packages/async_redux) [![pub package](https://img.shields.io/badge/Awesome-Flutter-blue.svg?longCache=true&style=flat-square)](https://github.com/Solido/awesome-flutter) [![GitHub stars](https://img.shields.io/github/stars/marcglasberg/async_redux?style=social)](https://github.com/marcglasberg/async_redux) ![Code Climate issues](https://img.shields.io/github/issues/marcglasberg/async_redux?style=flat-square) ![GitHub closed issues](https://img.shields.io/github/issues-closed/marcglasberg/async_redux?style=flat-square) ![GitHub contributors](https://img.shields.io/github/contributors/marcglasberg/async_redux?style=flat-square) ![GitHub repo size](https://img.shields.io/github/repo-size/marcglasberg/async_redux?style=flat-square) ![GitHub forks](https://img.shields.io/github/forks/marcglasberg/async_redux?style=flat-square) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square) [![Developed by Marcelo Glasberg](https://img.shields.io/badge/Developed%20by%20Marcelo%20Glasberg-blue.svg)](https://glasberg.dev/) [![Glasberg.dev on pub.dev](https://img.shields.io/pub/publisher/async_redux.svg)](https://pub.dev/publishers/glasberg.dev/packages) [![Platforms](https://badgen.net/pub/flutter-platform/async_redux)](https://pub.dev/packages/async_redux) #### Created by **[Marcelo Glasberg](https://glasberg.dev)** | [LinkedIn](https://linkedin.com/in/marcglasberg/) | [GitHub](https://github.com/marcglasberg/) #### Contributors #### Sponsor [![](./example/SponsoredByMyTextAi.png)](https://mytext.ai) # AsyncRedux | *state management* * Simple to learn, easy to use * Handles complex applications with millions of users * Testable You'll be able to create apps much faster, and other people on your team will easily understand and modify your code. ## What is it? An optimized reimagined version of Redux. A mature solution, battle-tested in hundreds of real-world applications. Created by [Marcelo Glasberg](https://github.com/marcglasberg) (see [all my packages](https://pub.dev/publishers/glasberg.dev/packages)). > There is also a version for React > [called Kiss State](https://kissforreact.org/) > If you use Bloc, check [Bloc Superpowers](https://pub.dev/packages/bloc_superpowers) > Optionally use AsyncRedux with [Provider](https://pub.dev/packages/provider_for_redux) > or [Flutter Hooks](https://pub.dev/packages/flutter_hooks_async_redux) # Documentation ### Complete docs → **https://asyncredux.com** ### Claude Code Skills → [Copy from the repo on GitHub](https://github.com/marcglasberg/async_redux/tree/main/.claude/skills)   _Below is a quick overview._ *** # Store and state The **store** holds all the application **state**. ```dart // The application state class AppState { final String name; final int age; AppState(this.name, this.age); } // Create the store with the initial state var store = Store( initialState: AppState('Mary', 25) ); ```   To use the store, add it in a `StoreProvider` at the top of your widget tree. ```dart Widget build(context) { return StoreProvider( store: store, child: MaterialApp( ... ), ); } ```   # Widgets use the state Using `context.state`, your widgets rebuild when the state changes. ```dart class MyWidget extends StatelessWidget { Widget build(context) => Text('${context.state.name} has ${context.state.age} years old'); } ``` Or use `context.select()` to get only the parts of the state you need. ```dart Widget build(context) { var state = context.select((st) => ( name: st.user.name, age: st.user.age), ); return Text('${state.name} has ${state.age} years old'); } ``` This also works: ```dart Widget build(context) { var name = context.select((st) => st.name); var age = context.select((st) => st.age); return Text('$name has $age years old'); } ```   # Actions change the state The application state is **immutable**, so the only way to change it is by **dispatching** an **action**. ```dart // Dispatch an action dispatch(Increment()); // Dispatch multiple actions dispatchAll([Increment(), LoadText()]); // Dispatch an action and wait for it to finish await dispatchAndWait(Increment()); // Dispatch multiple actions and wait for them to finish await dispatchAndWaitAll([Increment(), LoadText()]); ```   An **action** is a class with a name that describes what it does, like `Increment`, `LoadText`, or `BuyStock`. It must include a method called `reduce`. This "reducer" has access to the current state, and must return a new one to replace it. ```dart class Increment extends Action { // The reducer has access to the current state AppState reduce() => AppState(state.name, state.age + 1); // Returns new state } ```   # Widgets can dispatch actions In your widgets, use `context.dispatch` to dispatch actions. ```dart class MyWidget extends StatelessWidget { Widget build(context) { return ElevatedButton( onPressed: () => context.dispatch(Increment()); } } ```   # Actions can do asynchronous work Actions may download information from the internet, or do any other async work. ```dart class LoadText extends Action { // This reducer returns a Future Future reduce() async { // Download something from the internet var response = await http.get('https://dummyjson.com/todos/1'); var newName = state.response.body; // Change the state with the downloaded information return AppState(newName, state.age); } } ```   # Actions can throw errors If something bad happens, you can simply **throw an error**. In this case, the state will not change. Errors are caught globally and can be handled in a central place, later. In special, if you throw a `UserException`, which is a type provided by Async Redux, a dialog (or other UI) will open automatically, showing the error message to the user. ```dart class LoadText extends Action { Future reduce() async { var response = await http.get('https://dummyjson.com/todos/1'); if (response.statusCode == 200) return response.body; else throw UserException('Failed to load'); } } ```   To show a spinner while an asynchronous action is running, use `isWaiting(action)`. To show an error message inside the widget, use `isFailed(action)`. ```dart class MyWidget extends StatelessWidget { Widget build(context) { if (context.isWaiting(LoadText)) return CircularProgressIndicator(); if (context.isFailed(LoadText)) return Text('Loading failed...'); return Text(context.state); } } ```   # Actions can dispatch other actions You can use `dispatchAndWait` to dispatch an action and wait for it to finish. ```dart class LoadTextAndIncrement extends Action { Future reduce() async { // Dispatch and wait for the action to finish await dispatchAndWait(LoadText()); // Only then, increment the state return state.copy(count: state.count + 1); } } ```   You can also dispatch actions in **parallel** and wait for them to finish: ```dart class BuyAndSell extends Action { Future reduce() async { // Dispatch and wait for both actions to finish await dispatchAndWaitAll([ BuyAction('IBM'), SellAction('TSLA') ]); return state.copy(message: 'New cash balance is ${state.cash}'); } } ```   You can also use `waitCondition` to wait until the `state` changes in a certain way: ```dart class SellStockForPrice extends Action { final String stock; final double limitPrice; SellStockForPrice(this.stock, this.limitPrice); Future reduce() async { // Wait until the stock price is higher than the limit price await waitCondition( (st) => st.stocks[stock].price >= limitPrice ); // Only then, post the sell order to the backend var amount = await postSellOrder(stock); return state.copy( stocks: state.stocks.setAmount(stock, amount), ); } ```   # Add mixins to your actions You can use **mixins** to accomplish common tasks. ## Check for Internet connectivity Mixin `CheckInternet` ensures actions only run with internet, otherwise an **error dialog** prompts users to check their connection: ```dart class LoadText extends Action with CheckInternet { Future reduce() async { var response = await http.get('https://dummyjson.com/todos/1'); ... } } ```   Mixin `NoDialog` can be added to `CheckInternet` so that no dialog is opened. Instead, you can display some information in your widgets: ```dart class LoadText extends Action with CheckInternet, NoDialog { ... } class MyWidget extends StatelessWidget { Widget build(context) { if (context.isFailed(LoadText)) Text('No Internet connection'); } } ```   Mixin `AbortWhenNoInternet` aborts the action silently (without showing any dialogs) if there is no internet connection.   ## NonReentrant Mixin `NonReentrant` prevents an action from being dispatched while it's already running. ```dart class LoadText extends Action with NonReentrant { ... } ```   ## Retry Mixin `Retry` retries the action a few times with exponential backoff, if it fails. Add `UnlimitedRetries` to retry indefinitely: ```dart class LoadText extends Action with Retry, UnlimitedRetries { ... } ```   ## UnlimitedRetryCheckInternet Mixin `UnlimitedRetryCheckInternet` checks if there is internet when you run some action that needs it. If there is no internet, the action will abort silently and then retried unlimited times, until there is internet. It will also retry if there is internet but the action failed. ```dart class LoadText extends Action with UnlimitedRetryCheckInternet { ... } ```   ## Fresh Mixin `Fresh` prevents a dispatched action from reloading the same information while it is still up to date. The first dispatch always runs and loads the data. While the data is _fresh_, later dispatches do nothing. When the fresh period ends, the data becomes _stale_ and the action may run again. ```dart class LoadPrices extends Action with Fresh { final int freshFor = 5000; // Milliseconds Future reduce() async { var result = await loadJson('https://example.com/prices'); return state.copy(prices: result); } } ```   ## Throttle Mixin Throttle limits how often an action can run, acting as a simple rate limit. The first dispatch runs right away. Any later dispatches during the throttle period are ignored. Once the period ends, the next dispatch is allowed to run again. ```dart class RefreshFeed extends Action with Throttle { final int throttle = 3000; // Milliseconds Future reduce() async { final items = await loadJson('https://example.com/feed'); return state.copy(feedItems: items); } } ```   ## Debounce Mixin `Debounce` limits how often an action occurs in response to rapid inputs. For example, when a user types in a search bar, debouncing ensures that not every keystroke triggers a server request. Instead, it waits until the user pauses typing before acting. ```dart class SearchText extends Action with Debounce { final String searchTerm; SearchText(this.searchTerm); final int debounce = 300; // Milliseconds Future reduce() async { var response = await http.get( Uri.parse('https://example.com/?q=' + encoded(searchTerm)) ); return state.copy(searchResult: response.body); } } ```   ## Polling Mixin `Polling` runs an action periodically. Use it to keep data fresh by repeatedly fetching from a server. ```dart class GetStockPrice extends Action with Polling { final Poll poll; GetStockPrice([this.poll = Poll.once]); Duration get pollInterval => const Duration(minutes: 1); Action createPollingAction() => GetStockPrice(); Future reduce() async { var result = await loadJson('https://example.com/prices'); return state.copy(prices: result); } } ``` The `poll` parameter controls the behavior. ```dart // Run only once dispatch(GetStockPrice()); // Start polling dispatch(GetStockPrice(Poll.start)); // Stop polling dispatch(GetStockPrice(Poll.stop)); ```   ## OptimisticCommand Mixin `OptimisticCommand` helps you provide instant feedback on **blocking** actions that save information to the server. You immediately apply state changes as if they were already successful. The UI prevents the user from making other changes until the server confirms the update. If the update fails, the change is rolled back. ```dart class SaveTodo extends Action with OptimisticCommand { final Todo todo; SaveTodo(this.todo); async reduce() { ... } } ```   ## OptimisticSync Mixin `OptimisticSync` helps you provide instant feedback on **non-blocking** actions that save information to the server. The UI does **not** prevent the user from making other changes. Changes are applied locally right away, while the mixin synchronizes those changes with the server in the background. ```dart class SaveLike extends Action with OptimisticSync { final bool isLiked; SaveLike(this.isLiked); async reduce() { ... } } ```   ## OptimisticSyncWithPush Mixin `OptimisticSyncWithPush` is similar to `OptimisticSync`, but it also assumes that the app listens to the server, for example via WebSockets. It supports server versioning and multiple clients updating the same data concurrently. ```dart class SaveLike extends Action with OptimisticSyncWithPush { final bool isLiked; SaveLike(this.isLiked); async reduce() { ... } } ```   # Events You can use `Evt()` to create events that perform one-time operations, to work with widgets like **TextField** or **ListView** that manage their own internal state. ```dart // Action that changes the text of a TextField class ChangeText extends Action { final String newText; ChangeText(this.newText); AppState reduce() => state.copy(changeText: Evt(newText)); } } // Action that scrolls a ListView to the top class ScrollToTop extends Action { AppState reduce() => state.copy(scroll: Evt(0)); } } ``` Then, consume the events in your widgets: ```dart Widget build(context) { var clearText = context.event((st) => st.clearTextEvt); if (clearText) controller.clear(); var newText = context.event((st) => st.changeTextEvt); if (newText != null) controller.text = newText; return ... } ```   # Persist the state You can add a `persistor` to save the state to the local device disk. ```dart var store = Store( persistor: MyPersistor(), ); ```   # Testing your app is easy Just dispatch actions and wait for them to finish. Then, verify the new state or check if some error was thrown. ```dart class AppState { List items; int selectedItem; } test('Selecting an item', () async { var store = Store( initialState: AppState( items: ['A', 'B', 'C'] selectedItem: -1, // No item selected )); // Should select item 2 await store.dispatchAndWait(SelectItem(2)); expect(store.state.selectedItem, 'B'); // Fail to select item 42 var status = await store.dispatchAndWait(SelectItem(42)); expect(status.originalError, isA<>(UserException)); }); ```   # Advanced setup If you are the Team Lead, you set up the app's infrastructure in a central place, and allow your developers to concentrate solely on the business logic. You can add a `stateObserver` to collect app metrics, an `errorObserver` to log errors, an `actionObserver` to print information to the console during development, and a `globalWrapError` to catch all errors. ```dart var store = Store( stateObserver: [MyStateObserver()], errorObserver: [MyErrorObserver()], actionObservers: [MyActionObserver()], globalWrapError: MyGlobalWrapError(), ```   For example, the following `globalWrapError` handles `PlatformException` errors thrown by Firebase. It converts them into `UserException` errors, which are built-in types that automatically show a message to the user in an error dialog: ```dart Object? wrap(error, stackTrace, action) => (error is PlatformException) ? UserException('Error connecting to Firebase') : error; } ```   # Advanced action configuration The Team Lead may create a base action class that all actions will extend, and add some common functionality to it. For example, getter shortcuts to important parts of the state, and selectors to help find information. ```dart class AppState { List items; int selectedItem; } class Action extends ReduxAction { // Getter shortcuts List get items => state.items; Item get selectedItem => state.selectedItem; // Selectors Item? findById(int id) => items.firstWhereOrNull((item) => item.id == id); Item? searchByText(String text) => items.firstWhereOrNull((item) => item.text.contains(text)); int get selectedIndex => items.indexOf(selectedItem); } ```   Now, all actions can use them to access the state in their reducers: ```dart class SelectItem extends Action { final int id; SelectItem(this.id); AppState reduce() { Item? item = findById(id); if (item == null) throw UserException('Item not found'); return state.copy(selected: item); } } ```   # Claude Code Skills This package includes **Skills** that help you use `async_redux` with Claude Code and other AI agents. To use it, you have to copy the skills from [this repository](https://github.com/marcglasberg/async_redux/tree/master/.claude/skills) to your project. [Learn more](https://asyncredux.com/flutter/claude-code-skills). --- ### Complete docs → **https://asyncredux.com** *** ## Created by Marcelo Glasberg _glasberg.dev_
_github.com/marcglasberg_
_linkedin.com/in/marcglasberg/_
_twitter.com/glasbergmarcelo_
_stackoverflow.com/users/3411681/marcg_
_medium.com/@marcglasberg_
*I wrote Google's official Flutter documentation on layout rules*: * Understanding constraints *Flutter packages I've authored:* * bloc_superpowers * i18n_extension * async_redux * provider_for_redux * align_positioned * network_to_file_image * image_pixels * matrix4_transform * back_button_interceptor * indexed_list_view * animated_size_and_fade * assorted_layout_widgets * weak_map * themed * bdd_framework * tiktoken_tokenizer_gpt4o_o1 *The JavaScript/TypeScript packages I've authored:* * [Kiss State, for React](https://kissforreact.org/) (similar to AsyncRedux, but for React) * [Easy BDD Tool, for Jest](https://www.npmjs.com/package/easy-bdd-tool-jest) *My Medium Articles:* * Bloc Superpowers: Flutter/Dart package for enhancing your Cubits * AsyncRedux: Flutter’s non-boilerplate version of Redux (versions: Português) * i18n_extension (versions: Português) * Flutter: The Advanced Layout Rule Even Beginners Must Know (versions: русский) * The New Way to create Themes in your Flutter App ================================================ FILE: analysis_options.yaml ================================================ # Specify analysis options. # # Until there are meta linter rules, each desired lint must be explicitly enabled. # See: https://github.com/dart-lang/linter/issues/288 # # For a list of lints, see: http://dart-lang.github.io/linter/lints/ # See the configuration guide for more # https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer # # There are four similar analysis options files in the flutter repos: # - analysis_options.yaml (this file) # - packages/flutter/lib/analysis_options_user.yaml # - https://github.com/flutter/plugins/blob/master/analysis_options.yaml # - https://github.com/flutter/engine/blob/master/analysis_options.yaml # # This file contains the analysis options used by Flutter tools, such as IntelliJ, # Android Studio, and the `flutter analyze` command. # # The flutter/plugins repo contains a copy of this file, which should be kept # in sync with this file. analyzer: # strong-mode: # implicit-casts: false # implicit-dynamic: false errors: # treat missing required parameters as a warning (not a hint) missing_required_param: warning # treat missing returns as a warning (not a hint) missing_return: error # allow having TODOs in the code todo: ignore exclude: - 'bin/cache/**' - 'lib/src/http/**' # https://github.com/dart-lang/linter/blob/master/example/all.yaml linter: rules: - always_declare_return_types - annotate_overrides - avoid_empty_else - avoid_field_initializers_in_const_classes # - avoid_function_literals_in_foreach_calls - avoid_init_to_null - avoid_null_checks_in_equality_operators - avoid_relative_lib_imports - avoid_renaming_method_parameters - avoid_return_types_on_setters - avoid_slow_async_io - await_only_futures # - camel_case_types - cancel_subscriptions - control_flow_in_finally - directives_ordering - empty_catches - empty_constructor_bodies - empty_statements - hash_and_equals - implementation_imports - collection_methods_unrelated_type - library_names - library_prefixes - no_duplicate_case_values - overridden_fields - package_names - package_prefixed_library_names - prefer_adjacent_string_concatenation - prefer_asserts_in_initializer_lists - prefer_collection_literals - prefer_conditional_assignment - prefer_const_constructors # - prefer_const_constructors_in_immutables - prefer_const_declarations - prefer_contains - prefer_final_fields # - prefer_foreach - prefer_generic_function_type_aliases - prefer_initializing_formals - prefer_is_empty - prefer_is_not_empty - prefer_typing_uninitialized_variables - recursive_getters - slash_for_doc_comments - sort_unnamed_constructors_first - test_types_in_equals - throw_in_finally - type_init_formals - unnecessary_brace_in_string_interps - unnecessary_getters_setters - unnecessary_null_aware_assignments - unnecessary_null_in_if_null_operators - unnecessary_overrides - unnecessary_this - unrelated_type_equality_checks - use_rethrow_when_possible - valid_regexps # - unnecessary_new # - unnecessary_const # - non_constant_identifier_names # - always_put_control_body_on_new_line # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 # - always_specify_types # - avoid_annotating_with_dynamic # conflicts with always_specify_types # - avoid_as # - avoid_bool_literals_in_conditional_expressions # not yet tested # - avoid_catches_without_on_clauses # we do this commonly # - avoid_catching_errors # we do this commonly # - avoid_classes_with_only_static_members # - avoid_double_and_int_checks # only useful when targeting JS runtime # - avoid_js_rounded_ints # only useful when targeting JS runtime # - avoid_positional_boolean_parameters # not yet tested # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) # - avoid_returning_null # we do this commonly # - avoid_returning_this # https://github.com/dart-lang/linter/issues/842 # - avoid_setters_without_getters # not yet tested # - avoid_single_cascade_in_expression_statements # not yet tested # - avoid_types_as_parameter_names # https://github.com/dart-lang/linter/pull/954/files # - avoid_types_on_closure_parameters # conflicts with always_specify_types # - avoid_unused_constructor_parameters # https://github.com/dart-lang/linter/pull/847 # - cascade_invocations # not yet tested # - close_sinks # https://github.com/flutter/flutter/issues/5789 # - comment_references # blocked on https://github.com/dart-lang/dartdoc/issues/1153 # - constant_identifier_names # https://github.com/dart-lang/linter/issues/204 # - invariant_booleans # https://github.com/flutter/flutter/issues/5790 # - join_return_with_assignment # not yet tested # - literal_only_boolean_expressions # https://github.com/flutter/flutter/issues/5791 # - no_adjacent_strings_in_list # - omit_local_variable_types # opposite of always_specify_types # - one_member_abstracts # too many false positives # - only_throw_errors # https://github.com/flutter/flutter/issues/5792 # - parameter_assignments # we do this commonly # - prefer_const_literals_to_create_immutables # - prefer_constructors_over_static_methods # not yet tested # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods # - prefer_final_locals # - prefer_function_declarations_over_variables # not yet tested # - prefer_interpolation_to_compose_strings # not yet tested # - prefer_iterable_whereType # https://github.com/dart-lang/sdk/issues/32463 # - prefer_single_quotes # - sort_constructors_first # - type_annotate_public_apis # subset of always_specify_types # - unawaited_futures # https://github.com/flutter/flutter/issues/5793 # - unnecessary_lambdas # https://github.com/dart-lang/linter/issues/498 # - unnecessary_parenthesis # - unnecessary_statements # not yet tested # - use_setters_to_change_properties # not yet tested # - use_string_buffers # https://github.com/dart-lang/linter/pull/664 # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review # - void_checks # not yet tested ================================================ FILE: context_select_patterns.md ================================================ # Extension Patterns for AsyncRedux `getSelect` This guide demonstrates different extension patterns you can use with AsyncRedux's `context.select()` method to create clean, type-safe selectors in your Flutter apps. ## Table of Contents - [Pattern 1: Basic Extension (Recommended Minimum)](#pattern-1-basic-extension-recommended-minimum) - [Pattern 2: Type-Specific Selectors](#pattern-2-type-specific-selectors-for-better-intellisense) - [Pattern 3: Domain-Specific Selectors](#pattern-3-domain-specific-selectors-for-complex-apps) - [Pattern 4: Combined Selectors for Complex State](#pattern-4-combined-selectors-for-complex-state) - [Pattern 5: Nullable State Handling](#pattern-5-nullable-state-handling) - [Recommendations](#recommendations) ## Example App State All patterns below assume the following app state structure: ```dart import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Your app state class AppState { final User user; final List products; final Cart cart; final Settings settings; AppState({ required this.user, required this.products, required this.cart, required this.settings, }); } class User { final String name; final int age; final bool isPremium; User({required this.name, required this.age, required this.isPremium}); } class Product { final String id; final String name; final double price; Product({required this.id, required this.name, required this.price}); } class Cart { final List items; Cart({required this.items}); } class Settings { final bool darkMode; final String language; Settings({required this.darkMode, required this.language}); } ``` --- ## Pattern 1: Basic Extension (Recommended Minimum) This is the **recommended** starting point for most apps. It provides a clean, simple API with full type inference. ### Extension Definition ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ``` ### Usage Example ```dart class BasicExample extends StatelessWidget { @override Widget build(BuildContext context) { // Clean and simple - types are inferred! final userName = context.select((st) => st.user.name); final userAge = context.select((st) => st.user.age); final isPremium = context.select((st) => st.user.isPremium); return Column( children: [ Text('Name: $userName'), Text('Age: $userAge'), Text('Premium: $isPremium'), ], ); } } ``` ### Benefits - Simple and clean API - Full type inference - no need to specify types repeatedly - Minimal boilerplate - Access to full state via `context.state` when needed --- ## Pattern 2: Type-Specific Selectors (For Better IntelliSense) Add type-specific methods for common types to get better IDE autocomplete and type safety. ### Extension Definition ```dart extension TypedContextExtension on BuildContext { AppState get state => getState(); R _select(R Function(AppState state) selector) => getSelect(selector); // Type-specific methods for common types String selectString(String Function(AppState state) selector) => _select(selector); int selectInt(int Function(AppState state) selector) => _select(selector); bool selectBool(bool Function(AppState state) selector) => _select(selector); double selectDouble(double Function(AppState state) selector) => _select(selector); List selectList(List Function(AppState state) selector) => _select(selector); Map selectMap(Map Function(AppState state) selector) => _select(selector); } ``` ### Usage Example ```dart class TypedExample extends StatelessWidget { @override Widget build(BuildContext context) { // Explicit type methods can help with IDE autocomplete final userName = context.selectString((state) => state.user.name); final userAge = context.selectInt((state) => state.user.age); final isPremium = context.selectBool((state) => state.user.isPremium); final prices = context.selectList( (state) => state.products.map((p) => p.price).toList(), ); return Column( children: [ Text('Name: $userName'), Text('Age: $userAge'), Text('Premium: $isPremium'), Text('Prices: ${prices.join(', ')}'), ], ); } } ``` ### Benefits - Better IDE autocomplete - Explicit type declarations can help with complex nested types - Still maintains type safety --- ## Pattern 3: Domain-Specific Selectors (For Complex Apps) Create domain-specific getters for commonly accessed data. This is ideal for large apps with many screens that repeatedly access the same state slices. ### Extension Definition ```dart extension DomainContextExtension on BuildContext { AppState get state => getState(); R _select(R Function(AppState state) selector) => getSelect(selector); // User-specific selectors User get user => _select((state) => state.user); String get userName => _select((state) => state.user.name); int get userAge => _select((state) => state.user.age); bool get isPremiumUser => _select((state) => state.user.isPremium); // Cart-specific selectors List get cartItems => _select((state) => state.cart.items); int get cartItemCount => _select((state) => state.cart.items.length); double get cartTotal => _select( (state) => state.cart.items.fold(0.0, (sum, item) => sum + item.price), ); // Settings-specific selectors bool get isDarkMode => _select((state) => state.settings.darkMode); String get appLanguage => _select((state) => state.settings.language); // Computed selectors bool get hasItemsInCart => _select((state) => state.cart.items.isNotEmpty); bool get isEligibleForFreeShipping => _select( (state) => state.cart.items.fold(0.0, (sum, item) => sum + item.price) > 50, ); } ``` ### Usage Example ```dart class DomainExample extends StatelessWidget { @override Widget build(BuildContext context) { // Super clean - like accessing properties! return Column( children: [ Text('User: ${context.userName}'), Text('Age: ${context.userAge}'), Text('Premium: ${context.isPremiumUser}'), Text('Cart Items: ${context.cartItemCount}'), Text('Cart Total: \$${context.cartTotal}'), Text('Dark Mode: ${context.isDarkMode}'), if (context.hasItemsInCart) Text('Free Shipping: ${context.isEligibleForFreeShipping}'), ], ); } } ``` ### Benefits - Extremely clean usage - reads like natural properties - Encapsulates complex selector logic - Great for large apps with repeated access patterns - Centralizes state access logic --- ## Pattern 4: Combined Selectors for Complex State Use records or view models to select multiple related values at once, reducing the number of selector calls. ### Extension Definition ```dart extension CombinedContextExtension on BuildContext { R _select(R Function(AppState state) selector) => getSelect(selector); // Select multiple related values at once using records ({String name, int age, bool isPremium}) get userInfo => _select( (state) => ( name: state.user.name, age: state.user.age, isPremium: state.user.isPremium, ), ); // Select computed view models CartSummary get cartSummary => _select( (state) => CartSummary( itemCount: state.cart.items.length, total: state.cart.items.fold(0.0, (sum, item) => sum + item.price), isEmpty: state.cart.items.isEmpty, ), ); } class CartSummary { final int itemCount; final double total; final bool isEmpty; CartSummary({ required this.itemCount, required this.total, required this.isEmpty, }); @override bool operator ==(Object other) => identical(this, other) || other is CartSummary && itemCount == other.itemCount && total == other.total && isEmpty == other.isEmpty; @override int get hashCode => Object.hash(itemCount, total, isEmpty); } ``` ### Usage Example ```dart class CombinedExample extends StatelessWidget { @override Widget build(BuildContext context) { // Get multiple values with one selector final user = context.userInfo; final cart = context.cartSummary; return Column( children: [ Text('User: ${user.name}, ${user.age} years old'), Text('Premium: ${user.isPremium}'), Text('Cart: ${cart.itemCount} items, \$${cart.total}'), if (cart.isEmpty) Text('Your cart is empty'), ], ); } } ``` ### Benefits - Reduces number of selector calls - Groups related data logically - View models can encapsulate complex computations - Better performance when multiple values change together ### Important Note Remember to implement `==` and `hashCode` for view model classes to ensure proper change detection and prevent unnecessary rebuilds. --- ## Pattern 5: Nullable State Handling Handle optional or nullable state gracefully with default values and safe selectors. ### Extension Definition ```dart extension NullableContextExtension on BuildContext { R _select(R Function(AppState state) selector) => getSelect(selector); // Safe selectors with default values String selectUserName({String defaultValue = 'Guest'}) => _select( (state) => state.user.name.isEmpty ? defaultValue : state.user.name); int selectUserAge({int defaultValue = 0}) => _select((state) => state.user.age > 0 ? state.user.age : defaultValue); // Optional selectors T? selectOptional(T? Function(AppState state) selector) => _select(selector); } ``` ### Usage Example ```dart class NullableExample extends StatelessWidget { @override Widget build(BuildContext context) { // Get values with fallbacks final userName = context.selectUserName(defaultValue: 'Anonymous'); final userAge = context.selectUserAge(defaultValue: 18); return Column( children: [ Text('Name: $userName'), Text('Age: $userAge'), ], ); } } ``` ### Benefits - Gracefully handles missing or empty data - Provides sensible defaults - Reduces null checks in UI code --- ## Recommendations ### 1. Start Simple Use **Pattern 1** (Basic Extension) for most apps: ```dart extension BuildContextExtension on BuildContext { AppState get state => getState(); R select(R Function(AppState state) selector) => getSelect(selector); } ``` This gives you: - `context.state` for full state access - `context.select((state) => ...)` with automatic type inference - No need to specify AppState or return type repeatedly ### 2. Add Type-Specific Methods If you find yourself repeatedly selecting the same types and want better IDE support, add typed methods (Pattern 2). ### 3. Domain Methods for Large Apps For complex apps with many screens, create domain-specific getters (Pattern 3) for commonly accessed data. This makes your code more readable and maintainable. ### 4. Performance Tips - The selector function is called on every state change to check if a rebuild is needed - Keep selectors simple and fast - For expensive computations, consider caching/memoization - Avoid creating new objects in selectors unless necessary (or implement proper `==` and `hashCode`) ### 5. Testing Extensions make testing easier: - You can mock the context - Create test-specific extensions - Selectors are pure functions that are easy to test ### 6. Combining Patterns You can combine multiple patterns in a single extension: ```dart extension AppContextExtension on BuildContext { AppState get state => getState(); // Pattern 1: Generic selector R select(R Function(AppState state) selector) => getSelect(selector); // Pattern 3: Domain-specific selectors for common use cases String get userName => select((state) => state.user.name); int get cartItemCount => select((state) => state.cart.items.length); } ``` This provides both flexibility and convenience where you need it most. ================================================ FILE: example/.gitignore ================================================ # Miscellaneous *.class *.log *.pyc *.swp .DS_Store .atom/ .buildlog/ .history .svn/ # IntelliJ related *.iml *.ipr *.iws .idea/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line # is commented out by default. #.vscode/ # Flutter/Dart/Pub related **/.flutter-plugins-dependencies **/flutter_export_environment.sh **/doc/api/ .dart_tool/ .flutter-plugins .packages .pub-cache/ .pub/ /build/ build/ ios/.generated/ ios/Flutter/Generated.xcconfig ios/Runner/GeneratedPluginRegistrant.* pubspec.lock *.lock .flutter-plugins-dependencies # Android related **/android/**/gradle-wrapper.jar **/android/.gradle **/android/captures/ **/android/gradlew **/android/gradlew.bat **/android/local.properties **/android/**/GeneratedPluginRegistrant.java # iOS/XCode related **/ios/**/*.mode1v3 **/ios/**/*.mode2v3 **/ios/**/*.moved-aside **/ios/**/*.pbxuser **/ios/**/*.perspectivev3 **/ios/**/*sync/ **/ios/**/.sconsign.dblite **/ios/**/.tags* **/ios/**/.vagrant/ **/ios/**/DerivedData/ **/ios/**/Icon? **/ios/**/Pods/ **/ios/**/.symlinks/ **/ios/**/profile **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework **/ios/Flutter/Flutter.framework **/ios/Flutter/Generated.xcconfig **/ios/Flutter/app.flx **/ios/Flutter/app.zip **/ios/Flutter/flutter_assets/ **/ios/ServiceDefinitions.json **/ios/Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !**/ios/**/default.mode1v3 !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages ================================================ FILE: example/.metadata ================================================ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # # This file should be version controlled and should not be manually edited. version: revision: "9f455d2486bcb28cad87b062475f42edc959f636" channel: "stable" project_type: app # Tracks metadata for the flutter migrate command migration: platforms: - platform: root create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 - platform: android create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 - platform: ios create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 - platform: web create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 - platform: windows create_revision: 9f455d2486bcb28cad87b062475f42edc959f636 base_revision: 9f455d2486bcb28cad87b062475f42edc959f636 # User provided section # List of Local paths (relative to this file) that should be # ignored by the migrate tool. # # Files that are not part of the templates will be ignored by default. unmanaged_files: - 'lib/main.dart' - 'ios/Runner.xcodeproj/project.pbxproj' ================================================ FILE: example/README.md ================================================ # Examples 1. main This example shows a counter and a button. When the button is tapped, the counter will increment synchronously. In this simple example, the app state is simply a number (the counter), and thus the store is defined as `Store`. The initial state is `0`. 2. main_increment_async This example shows a counter, a text description, and a button. When the button is tapped, the counter will increment synchronously, while an async process downloads some text description that relates to the counter number. 3. main_before_and_after This example shows a counter, a text description, and a button. When the button is tapped, the counter will increment synchronously, while an async process downloads some text description that relates to the counter number. While the async process is running, a redish modal barrier will prevent the user from tapping the button. The model barrier is removed even if the async process ends with an error, which can be simulated by turning off the internet connection (putting the phone in airplane mode). 4. main_static_view_model This example shows how to use the same `ViewModel` architecture of flutter_redux. This is specially useful if you are migrating from flutter_redux. Here, you use the `StoreConnector`'s `converter` parameter, instead of the `vm` parameter. And `ViewModel` doesn't extend `Vm`, but has a static factory: `converter: (store) => ViewModel.fromStore(store)`. 5. main_before_and_after_STATE_test This example displays the testing capabilities of AsyncRedux: How to test the store, actions, sync and async reducers, by using the StoreTester. **Important:** To run the tests, put this file in a test directory. 6. main_show_error_dialog This example lets you enter a name and click save. If the name has less than 4 chars, an error dialog will be shown. 7. main_navigate This example shows a route in the screen, all red. When you tap the screen it will push a new route, all blue. When you tap the screen again it will pop the blue route. 8. main_event This example shows a text-field, and two buttons. When the first button is tapped, an async process downloads some text from the internet and puts it in the text-field. When the second button is tapped, the text-field is cleared. This is meant to demonstrate the use of *events* to change a controller state. It also demonstrates the use of an abstract class to override the action's `before()` and `after()` methods. 9. main_infinite_scroll.dart This example demonstrates how to get a `Future` that completes when an action is done. It shows a list of number descriptions. If you pull to refresh the page (scroll above the top of the page) a `RefreshIndicator` will appear until the list is updated with different data. 10. main_wait_action_simple This example is the same as the one in `main_before_and_after.dart`. However, instead of declaring a `MyWaitAction`, it uses the build-in `WaitAction`. 11. main_wait_action_advanced_1 This example demonstrates how to use `WaitAction` in advanced ways. 10 buttons are shown. When a button is clicked it will be replaced by a downloaded text description. Each button shows a progress indicator while its description is downloading. The screen title shows the text "Downloading..." if any of the buttons is currently downloading. 12. main_wait_action_advanced_2 This example is the same as the one in `main_wait_action_advanced_1.dart`. However, instead of only using flags in the `WaitAction`, it uses both flags and references. ================================================ FILE: example/analysis_options.yaml ================================================ # This file configures the analyzer, which statically analyzes Dart code to # check for errors, warnings, and lints. # # The issues identified by the analyzer are surfaced in the UI of Dart-enabled # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be # invoked from the command line by running `flutter analyze`. # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` # included above or to enable additional rules. A list of all available lints # and their documentation is published at https://dart.dev/lints. # # Instead of disabling a lint rule for the entire project in the # section below, it can also be suppressed for a single line of code # or a specific dart file by using the `// ignore: name_of_lint` and # `// ignore_for_file: name_of_lint` syntax on the line or in the file # producing the lint. rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options ================================================ FILE: example/android/.gitignore ================================================ gradle-wrapper.jar /.gradle /captures/ /gradlew /gradlew.bat /local.properties GeneratedPluginRegistrant.java .cxx/ # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks ================================================ FILE: example/android/app/build.gradle.kts ================================================ plugins { id("com.android.application") id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { namespace = "com.example.example" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.example.example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") } } } flutter { source = "../.." } ================================================ FILE: example/android/app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: example/android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: example/android/app/src/main/kotlin/com/example/example/MainActivity.kt ================================================ package com.example.example import io.flutter.embedding.android.FlutterActivity class MainActivity : FlutterActivity() ================================================ FILE: example/android/app/src/main/res/drawable/launch_background.xml ================================================ ================================================ FILE: example/android/app/src/main/res/drawable-v21/launch_background.xml ================================================ ================================================ FILE: example/android/app/src/main/res/values/styles.xml ================================================ ================================================ FILE: example/android/app/src/main/res/values-night/styles.xml ================================================ ================================================ FILE: example/android/app/src/profile/AndroidManifest.xml ================================================ ================================================ FILE: example/android/build.gradle.kts ================================================ allprojects { repositories { google() mavenCentral() } } val newBuildDir: Directory = rootProject.layout.buildDirectory .dir("../../build") .get() rootProject.layout.buildDirectory.value(newBuildDir) subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } subprojects { project.evaluationDependsOn(":app") } tasks.register("clean") { delete(rootProject.layout.buildDirectory) } ================================================ FILE: example/android/gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip ================================================ FILE: example/android/gradle.properties ================================================ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true ================================================ FILE: example/android/settings.gradle.kts ================================================ pluginManagement { val flutterSdkPath = run { val properties = java.util.Properties() file("local.properties").inputStream().use { properties.load(it) } val flutterSdkPath = properties.getProperty("flutter.sdk") require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } flutterSdkPath } includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() mavenCentral() gradlePluginPortal() } } plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") ================================================ FILE: example/ios/.gitignore ================================================ **/dgph *.mode1v3 *.mode2v3 *.moved-aside *.pbxuser *.perspectivev3 **/*sync/ .sconsign.dblite .tags* **/.vagrant/ **/DerivedData/ Icon? **/Pods/ **/.symlinks/ profile xcuserdata **/.generated/ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ Flutter/flutter_export_environment.sh ServiceDefinitions.json Runner/GeneratedPluginRegistrant.* # Exceptions to above rules. !default.mode1v3 !default.mode2v3 !default.pbxuser !default.perspectivev3 ================================================ FILE: example/ios/Flutter/AppFrameworkInfo.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable App CFBundleIdentifier io.flutter.flutter.app CFBundleInfoDictionaryVersion 6.0 CFBundleName App CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1.0 MinimumOSVersion 13.0 ================================================ FILE: example/ios/Flutter/Debug.xcconfig ================================================ #include "Generated.xcconfig" ================================================ FILE: example/ios/Flutter/Release.xcconfig ================================================ #include "Generated.xcconfig" ================================================ FILE: example/ios/Runner/AppDelegate.swift ================================================ import Flutter import UIKit @main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } ================================================ FILE: example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@3x.png", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "Icon-App-29x29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "Icon-App-40x40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "Icon-App-60x60@3x.png", "scale" : "3x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@1x.png", "scale" : "1x" }, { "size" : "20x20", "idiom" : "ipad", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "Icon-App-29x29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "Icon-App-40x40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "Icon-App-76x76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "Icon-App-83.5x83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md ================================================ # Launch Screen Assets You can customize the launch screen with your own desired assets by replacing the image files in this directory. You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. ================================================ FILE: example/ios/Runner/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: example/ios/Runner/Base.lproj/Main.storyboard ================================================ ================================================ FILE: example/ios/Runner/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName Example CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName example CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) CFBundleSignature ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight CADisableMinimumFrameDurationOnPhone UIApplicationSupportsIndirectInputEvents ================================================ FILE: example/ios/Runner/Runner-Bridging-Header.h ================================================ #import "GeneratedPluginRegistrant.h" ================================================ FILE: example/ios/Runner.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; proxyType = 1; remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, ); path = RunnerTests; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); name = Flutter; sourceTree = ""; }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, ); sourceTree = ""; }; 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 331C8080294A63A400263BE5 /* RunnerTests */ = { isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, ); buildRules = ( ); dependencies = ( 331C8086294A63A400263BE5 /* PBXTargetDependency */, ); name = RunnerTests; productName = RunnerTests; productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, ); buildRules = ( ); dependencies = ( ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 97C146E51CF9000F007C117D; productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); name = "Run Script"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 97C146FB1CF9000F007C117D /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 97C147001CF9000F007C117D /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Profile; }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Debug; }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Release; }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 97C147041CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 97C147061CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 97C147071CF9000F007C117D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 331C8088294A63A400263BE5 /* Debug */, 331C8089294A63A400263BE5 /* Release */, 331C808A294A63A400263BE5 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147031CF9000F007C117D /* Debug */, 97C147041CF9000F007C117D /* Release */, 249021D3217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( 97C147061CF9000F007C117D /* Debug */, 97C147071CF9000F007C117D /* Release */, 249021D4217E4FDB00AE95B9 /* Profile */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } ================================================ FILE: example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme ================================================ ================================================ FILE: example/ios/Runner.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ PreviewsEnabled ================================================ FILE: example/ios/RunnerTests/RunnerTests.swift ================================================ import Flutter import UIKit import XCTest class RunnerTests: XCTestCase { func testExample() { // If you add code to the Runner application, consider adding tests here. // See https://developer.apple.com/documentation/xctest for more information about using XCTest. } } ================================================ FILE: example/lib/main.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a counter and a button. /// When the button is tapped, the counter will increment synchronously. /// /// In this simple example, the app state is simply a number (the counter), /// and thus the store is defined as `Store`. The initial state is 0. /// void main() { store = Store(initialState: 0); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), ), ); } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override int reduce() => state + amount; } /// This is a "smart-widget" that directly accesses the store state using /// `context.state` and dispatches actions using `dispatch`. class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { // In this simple example, the counter is the state. // This will rebuild whenever the state changes. // In more complex cases where we want the widget to rebuild only when // specific parts of the state change, we can use `context.select` instead. final counter = context.state; return Scaffold( appBar: AppBar(title: const Text('Increment Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( // Dispatch action directly from widget onPressed: () => dispatch(IncrementAction(amount: 1)), child: const Icon(Icons.add), ), ); } } /// Recommended to create this extension. extension BuildContextExtension on BuildContext { int get state => getState(); int read() => getRead(); R select(R Function(int state) selector) => getSelect(selector); } ================================================ FILE: example/lib/main_before_and_after.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a counter, a text description, and a button. /// When the button is tapped, the counter will increment synchronously, /// while an async process downloads some text description that relates /// to the counter number (using the Star Wars API: https://swapi.dev). /// /// While the async process is running, a reddish modal barrier will prevent /// the user from tapping the button. The model barrier is removed even if /// the async process ends with an error, which can be simulated by turning /// off the internet connection (putting the phone in airplane mode). /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter, a description, and a waiting flag. @immutable class AppState { final int counter; final String description; final bool waiting; AppState({ required this.counter, required this.description, required this.waiting, }); AppState copy({int? counter, String? description, bool? waiting}) => AppState( counter: counter ?? this.counter, description: description ?? this.description, waiting: waiting ?? this.waiting, ); static AppState initialState() => AppState(counter: 0, description: "", waiting: false); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && description == other.description && waiting == other.waiting; @override int get hashCode => counter.hashCode ^ description.hashCode ^ waiting.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), )); } /// This action increments the counter by 1, /// and then gets some description text relating to the new counter number. class IncrementAndGetDescriptionAction extends ReduxAction { // // Async reducer. // To make it async we simply return Future instead of AppState. @override Future reduce() async { // First, we increment the counter, synchronously. dispatch(IncrementAction(amount: 1)); // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; // After we get the response, we can modify the state with it, // without having to dispatch another action. return state.copy(description: description); } // This adds a modal barrier while the async process is running. @override void before() => dispatch(BarrierAction(true)); // This removes the modal barrier when the async process ends, // even if there was some error in the process. // You can test it by turning off the internet connection. @override void after() => dispatch(BarrierAction(false)); } class BarrierAction extends ReduxAction { final bool waiting; BarrierAction(this.waiting); @override AppState reduce() { return state.copy(waiting: waiting); } } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); // Synchronous reducer. @override AppState reduce() => state.copy(counter: state.counter + amount); } class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final counter = context.select((st) => st.counter); final description = context.select((st) => st.description); final waiting = context.select((st) => st.waiting); return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Before and After Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)), Text( description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: () => dispatch(IncrementAndGetDescriptionAction()), child: const Icon(Icons.add), ), ), if (waiting) ModalBarrier(color: Colors.red.withAlpha((255.0 * 0.4).round())), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_dependency_injection.dart ================================================ import 'dart:math'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows how to provide both an environment and dependencies to the Store, /// to help with dependency injection. The "dependencies" is a container for the /// injected services. You can have many dependency implementations, one /// for production, others for tests etc. In this case, we're using the /// [DependenciesProduction]. /// /// You should extend [ReduxAction] to provide typed access to the [Dependencies] /// inside your actions. /// /// You should also define a context extension (See [BuildContextExtension.environment] /// below) to provide typed access to the [Environment] inside your widgets. /// void main() { // store = Store( initialState: 0, environment: Environment.production, dependencies: (store) => Dependencies(store), ); runApp(MyApp()); } enum Environment { production, staging, testing; bool get isProduction => this == Environment.production; bool get isStaging => this == Environment.staging; bool get isTesting => this == Environment.testing; } /// The Dependencies class is a container for the injected services. /// We can have many dependency implementations, one for production, others for /// staging, tests etc. abstract class Dependencies { factory Dependencies(Store store) { if (store.environment == Environment.production) { return DependenciesProduction(); } else if (store.environment == Environment.staging) { return DependenciesStaging(); } else { return DependenciesTesting(); } } /// This demonstrates how the environment can be used to change the behavior of the /// dependencies. In this case, we have a method that limits the counter value, /// and the limit is different in production: /// - We limit the counter at 5, when in production /// - We limit the counter at 1000, when in staging or testing. int limit(int value); } /// Limit is 5 in production. class DependenciesProduction implements Dependencies { @override int limit(int value) => min(value, 5); } /// Limit is 25 in staging. class DependenciesStaging implements Dependencies { @override int limit(int value) => min(value, 25); } /// Limit is 1000 in testing. class DependenciesTesting implements Dependencies { @override int limit(int value) => min(value, 1000); } /// Extend [ReduxAction] to provide typed access to the [Dependencies]. abstract class Action extends ReduxAction { Dependencies get dependencies => super.store.dependencies as Dependencies; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), ), ); } } /// This action increments the counter by [amount], using [env]. class IncrementAction extends Action { final int amount; IncrementAction({required this.amount}); @override int reduce() { int newState = state + amount; int limitedState = dependencies.limit(newState); return limitedState; } } class MyHomePage extends StatelessWidget { @override Widget build(BuildContext context) { final env = context.environment; int counter = context.state; return Scaffold( appBar: AppBar(title: const Text('Dependency Injection Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // We can use the environment to change the UI as well. Text('Running in ${env}.', textAlign: TextAlign.center), // const Text( 'You have pushed the button this many times:\n' '(limited by the environment)', textAlign: TextAlign.center, ), // Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( onPressed: () => dispatch(IncrementAction(amount: 1)), child: const Icon(Icons.add), ), ); } } extension BuildContextExtension on BuildContext { int get state => getState(); int read() => getRead(); R select(R Function(int state) selector) => getSelect(selector); R? event(Evt Function(int state) selector) => getEvent(selector); /// This is in case the UI needs to know if we are in production, staging or testing. Environment get environment => getEnvironment() as Environment; } ================================================ FILE: example/lib/main_event.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example is meant to demonstrate the use of "events" (of type [Event] or /// [Evt]) to change a controller state, or perform any other one-time operation. /// /// It shows a text-field, and two buttons. /// When the first button is tapped, an async process downloads /// some text from the internet and puts it in the text-field. /// When the second button is tapped, the text-field is cleared. /// /// It also demonstrates the use of an abstract class [BarrierAction] /// to override the action's before() and after() methods. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter and two events. @immutable class AppState { final int counter; final bool waiting; final Event clearTextEvt; final Event changeTextEvt; AppState({ required this.counter, required this.waiting, required this.clearTextEvt, required this.changeTextEvt, }); AppState copy({ int? counter, bool? waiting, Event? clearTextEvt, Event? changeTextEvt, }) => AppState( counter: counter ?? this.counter, waiting: waiting ?? this.waiting, clearTextEvt: clearTextEvt ?? this.clearTextEvt, changeTextEvt: changeTextEvt ?? this.changeTextEvt, ); static AppState initialState() => AppState( counter: 1, waiting: false, clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && waiting == other.waiting; @override int get hashCode => counter.hashCode ^ waiting.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), ), ); } /// This action orders the text-controller to clear. class ClearTextAction extends ReduxAction { @override AppState reduce() => state.copy(clearTextEvt: Event()); } /// Actions that extend [BarrierAction] show a modal barrier while their async processes run. abstract class BarrierAction extends ReduxAction { @override void before() => dispatch(_WaitAction(true)); @override void after() => dispatch(_WaitAction(false)); } class _WaitAction extends ReduxAction { final bool waiting; _WaitAction(this.waiting); @override AppState reduce() => state.copy(waiting: waiting); } /// This action downloads some new text, and then creates an event /// that tells the text-controller to display that new text. class ChangeTextAction extends BarrierAction { @override Future reduce() async { // // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String newText = json['name'] ?? 'Unknown Star Wars character'; return state.copy( counter: state.counter + 1, changeTextEvt: Event(newText), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key? key}) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { late TextEditingController controller; @override void initState() { super.initState(); controller = TextEditingController(); } @override Widget build(BuildContext context) { // var waiting = context.select((state) => state.waiting); // Event that tells the controller to clear its text. var clearText = context.event((state) => state.clearTextEvt); if (clearText) controller.clear(); // Event that tells the controller to change its text. var newText = context.event((state) => state.changeTextEvt); if (newText != null) controller.text = newText; return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Event Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('This is a TextField. Click to edit it:'), TextField(controller: controller), const SizedBox(height: 20), FloatingActionButton( onPressed: () => dispatch(ChangeTextAction()), child: const Text("Change"), ), const SizedBox(height: 20), FloatingActionButton( onPressed: () => dispatch(ClearTextAction()), child: const Text("Clear"), ), ], ), ), ), if (waiting) ModalBarrier(color: Colors.red.withAlpha((255.0 * 0.4).round())), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_event_2.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example is similar to example `main_event.dart`, meant to demonstrate /// the use of "events" (of type [Event] or [Evt]) to change a controller state, /// or perform any other one-time operation. /// /// However, here we consume the events in the `didChangeDependencies()` method /// of the stateful widget, instead of in the `build()` method. /// /// To allow that, we need to turn off the debug mode of the `getEvent()` method, /// as shown in the extension method below: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// R? event(Evt Function(AppState state) selector) => /// getEvent(selector, debug: false); /// } /// ``` /// /// Use with care, as invalid usage in methods like `initState` will /// no longer be detected once the debug check is off. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter and two events. @immutable class AppState { final int counter; final bool waiting; final Event clearTextEvt; final Event changeTextEvt; AppState({ required this.counter, required this.waiting, required this.clearTextEvt, required this.changeTextEvt, }); AppState copy({ int? counter, bool? waiting, Event? clearTextEvt, Event? changeTextEvt, }) => AppState( counter: counter ?? this.counter, waiting: waiting ?? this.waiting, clearTextEvt: clearTextEvt ?? this.clearTextEvt, changeTextEvt: changeTextEvt ?? this.changeTextEvt, ); static AppState initialState() => AppState( counter: 1, waiting: false, clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && waiting == other.waiting; @override int get hashCode => counter.hashCode ^ waiting.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), ), ); } /// This action orders the text-controller to clear. class ClearTextAction extends ReduxAction { @override AppState reduce() => state.copy(clearTextEvt: Event()); } /// Actions that extend [BarrierAction] show a modal barrier while their async processes run. abstract class BarrierAction extends ReduxAction { @override void before() => dispatch(_WaitAction(true)); @override void after() => dispatch(_WaitAction(false)); } class _WaitAction extends ReduxAction { final bool waiting; _WaitAction(this.waiting); @override AppState reduce() => state.copy(waiting: waiting); } /// This action downloads some new text, and then creates an event /// that tells the text-controller to display that new text. class ChangeTextAction extends BarrierAction { @override Future reduce() async { // // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String newText = json['name'] ?? 'Unknown Star Wars character'; return state.copy( counter: state.counter + 1, changeTextEvt: Event(newText), ); } } class MyHomePage extends StatefulWidget { MyHomePage({Key? key}) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { late TextEditingController controller; @override void initState() { super.initState(); controller = TextEditingController(); } @override void didChangeDependencies() { super.didChangeDependencies(); // Event that tells the controller to clear its text. var clearText = context.event((state) => state.clearTextEvt); if (clearText) controller.clear(); // Event that tells the controller to change its text. var newText = context.event((state) => state.changeTextEvt); if (newText != null) controller.text = newText; } @override Widget build(BuildContext context) { // var waiting = context.select((state) => state.waiting); return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Event in didChangeDependencies Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('This is a TextField. Click to edit it:'), TextField(controller: controller), const SizedBox(height: 20), FloatingActionButton( onPressed: () => dispatch(ChangeTextAction()), child: const Text("Change"), ), const SizedBox(height: 20), FloatingActionButton( onPressed: () => dispatch(ClearTextAction()), child: const Text("Clear"), ), ], ), ), ), if (waiting) ModalBarrier(color: Colors.red.withAlpha((255.0 * 0.4).round())), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector, debug: false); } ================================================ FILE: example/lib/main_infinite_scroll.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a List of Star Wars characters. /// /// - Scrolling to the bottom of the list will async load the next 20 characters. /// /// - Scrolling past the top of the list (pull to refresh) will use /// `dispatchAndWait` to dispatch an action and get a future that tells the /// `RefreshIndicator` when the action completes. /// /// - `isWaiting(LoadMoreAction)` prevents the user from loading more while the /// async action is running. /// void main() { var state = AppState.initialState(); store = Store( initialState: state, actionObservers: [Log.printer()], modelObserver: DefaultModelObserver(), ); runApp(MyApp()); } @immutable class AppState { final List numTrivia; AppState({required this.numTrivia}); AppState copy({List? numTrivia}) => AppState(numTrivia: numTrivia ?? this.numTrivia); static AppState initialState() => AppState(numTrivia: []); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && numTrivia == other.numTrivia; @override int get hashCode => numTrivia.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( debugShowCheckedModeBanner: false, home: MyHomePage(), ), ); } class LoadMoreAction extends ReduxAction { @override Future reduce() async { List list = List.from(state.numTrivia); int start = state.numTrivia.length + 1; // Fetch 20 people concurrently. final responses = await Future.wait( List.generate(20, (i) => get(Uri.parse('https://swapi.dev/api/people/${start + i}/'))), ); for (final response in responses) { if (response.statusCode == 200) { final data = jsonDecode(response.body); list.add(data['name'] ?? 'Unknown character'); } } return state.copy(numTrivia: list); } } class RefreshAction extends ReduxAction { @override Future reduce() async { List list = []; // Fetch the first 20 people concurrently. final responses = await Future.wait( List.generate( 20, (i) => get(Uri.parse('https://swapi.dev/api/people/${i + 1}/'))), ); for (final response in responses) { if (response.statusCode == 200) { final data = jsonDecode(response.body); list.add(data['name'] ?? 'Unknown character'); } } return state.copy(numTrivia: list); } } /// This is a "smart-widget" that directly accesses the store to select state /// and dispatch actions, using context.select(), dispatch(), etc. class MyHomePage extends StatefulWidget { MyHomePage({Key? key}) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { late ScrollController _controller; @override void initState() { super.initState(); // Dispatch the initial refresh action dispatch(RefreshAction()); _controller = ScrollController()..addListener(_scrollListener); } void _scrollListener() { // Get the current loading state final isLoading = context.isWaiting(LoadMoreAction); // Load more when scrolled to the bottom if (!isLoading && _controller.position.maxScrollExtent == _controller.position.pixels) { context.dispatch(LoadMoreAction()); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Select only the numTrivia list from state. Rebuilds only when numTrivia changes. final numTrivia = context.select((state) => state.numTrivia); // Check if LoadMoreAction is currently running final isLoading = context.isWaiting(LoadMoreAction); return Scaffold( appBar: AppBar(title: const Text('Infinite Scroll Example')), body: numTrivia.isEmpty ? Container() : RefreshIndicator( onRefresh: () => context.dispatchAndWait(RefreshAction()), child: ListView.builder( controller: _controller, itemCount: numTrivia.length + 1, itemBuilder: (context, index) { // Show loading spinner at the end if (index == numTrivia.length) { return Padding( padding: EdgeInsets.all(8.0), child: Center( child: isLoading ? CircularProgressIndicator() : SizedBox(height: 30), ), ); } else { return ListTile( leading: CircleAvatar(child: Text(index.toString())), title: Text(numTrivia[index]), ); } }, ), ), ); } } /// Recommended extension methods for accessing state and dispatching actions. extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_is_waiting_works_when_multiple_actions.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; /// This example shows how to show a spinner while any of two actions /// ([IncrementAction] and [MultiplyAction]) is running. /// /// Writing this: /// /// ```dart /// isWaiting([IncrementAction, MultiplyAction]) /// ``` /// /// Is the same as writing this: /// /// ```dart /// isWaiting(IncrementAction) || isWaiting(MultiplyAction) /// ``` /// /// The `isCalculating` variable is defined in the `build` method /// of widget [MyHomePage]: /// /// ```dart /// bool isCalculating = isWaiting([IncrementAction, MultiplyAction]); /// ``` /// /// In more detail: /// - There are two floating action buttons: one to increment the counter /// and another to multiply it by 2. /// - When any of the buttons is tapped, its respective action is dispatched. /// - While any of the actions is running, both buttons show a spinner /// and are disabled. /// void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { var store = Store(initialState: AppState(counter: 0)); store.onChange.listen(print); return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: StoreProvider( store: store, child: const MyHomePage(), ), ); } } class MyHomePage extends StatelessWidget { const MyHomePage({super.key}); @override Widget build(BuildContext context) { int counter = context.select((state) => state.counter); bool isCalculating = context.isWaiting([IncrementAction, MultiplyAction]); return MyHomePageContent( title: 'IsWaiting multiple actions', counter: counter, isCalculating: isCalculating, increment: () => dispatch(IncrementAction()), multiply: () => dispatch(MultiplyAction()), ); } } class MyHomePageContent extends StatelessWidget { const MyHomePageContent({ super.key, required this.title, required this.counter, required this.isCalculating, required this.increment, required this.multiply, }); final String title; final int counter; final bool isCalculating; final VoidCallback increment, multiply; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Result:'), Text( '$counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: Column( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton( onPressed: isCalculating ? null : increment, elevation: isCalculating ? 0 : 6, backgroundColor: isCalculating ? Colors.grey[300] : Colors.blue, child: isCalculating ? const Padding( padding: const EdgeInsets.all(16.0), child: const CircularProgressIndicator(), ) : const Icon(Icons.add), ), const SizedBox(height: 16), FloatingActionButton( onPressed: isCalculating ? null : multiply, elevation: isCalculating ? 0 : 6, backgroundColor: isCalculating ? Colors.grey[300] : Colors.blue, child: isCalculating ? const Padding( padding: const EdgeInsets.all(16.0), child: const CircularProgressIndicator(), ) : const Icon(Icons.close), ) ], ), ); } } class AppState { final int counter; AppState({required this.counter}); AppState copy({int? counter}) => AppState(counter: counter ?? this.counter); @override String toString() { return '.\n.\n.\nAppState{counter: $counter}\n.\n.\n'; } } class IncrementAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 1)); return AppState(counter: state.counter + 1); } } class MultiplyAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 1)); return AppState(counter: state.counter * 2); } } /// Recommended to create this extension. extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_navigate.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; final navigatorKey = GlobalKey(); void main() async { NavigateAction.setNavigatorKey(navigatorKey); store = Store(initialState: AppState()); runApp(MyApp()); } final routes = { '/': (BuildContext context) => Page1(), "/myRoute": (BuildContext context) => Page2(), }; class AppState {} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( routes: routes, navigatorKey: navigatorKey, ), ); } } class Page extends StatelessWidget { final Color? color; final String? text; final VoidCallback onChangePage; Page({this.color, this.text, required this.onChangePage}); @override Widget build(BuildContext context) => ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: color), child: Text(text!), onPressed: onChangePage, ); } class Page1 extends StatelessWidget { @override Widget build(BuildContext context) { return Page( color: Colors.red, text: "Tap me to push a new route!", onChangePage: () => dispatch(NavigateAction.pushNamed("/myRoute")), ); } } class Page2 extends StatelessWidget { @override Widget build(BuildContext context) { return Page( color: Colors.blue, text: "Tap me to pop this route!", onChangePage: () => dispatch(NavigateAction.pop()), ); } } ================================================ FILE: example/lib/main_optimistic_command.dart ================================================ /// This example is meant to demonstrate the [OptimisticCommand] mixin in action. /// The screen is split into two halves: the top shows the UI state (Redux), and /// the bottom shows the simulated database state (server). /// /// ## Use cases to try: /// /// ### 1. Optimistic update /// Tap the heart icon. Notice the UI updates instantly (top half), while the /// database takes ~3.5 seconds to update (bottom half shows "Saving..."). /// /// ### 2. Non-reentrant behavior /// While "Saving..." is displayed, notice the button becomes semi-transparent /// and disabled. This prevents conflicting concurrent requests. /// /// ### 3. Server response applied /// After saving completes, both halves show the same state. The server response /// is applied to ensure the UI reflects the actual saved value. /// /// ### 4. Rollback on error /// First tap the heart to start saving. While "Saving..." is displayed, tap /// "Request fails" at the bottom. The UI will rollback to its previous state /// once the simulated error occurs. /// /// ### 5. External database changes (no push) /// Use the "Liked" or "Not Liked" buttons at the bottom to change the database /// directly. The UI may update only if a request is still in progress, because /// the request response will overwrite the UI state when it completes. /// But when there is no request in progress, the UI state won't update, /// because OptimisticCommand doesn't support push notifications. The UI only /// syncs when you tap the heart again. /// /// Note: If you use push, try mixin [OptimisticSyncWithPush] instead. /// import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import "package:meta/meta.dart"; late Store store; void main() { store = Store( initialState: AppState(liked: false), actionObservers: [ConsoleActionObserver()], ); runApp(const MyApp()); } class AppState { final bool liked; AppState({required this.liked}); @useResult AppState copy({bool? isLiked}) => AppState(liked: isLiked ?? this.liked); @override String toString() => 'AppState(liked: $liked)'; } class SetLike extends AppAction { final bool isLiked; SetLike(this.isLiked); @override AppState reduce() => state.copy(isLiked: isLiked); @override String toString() => '${super.toString()}($isLiked)'; } class ToggleLike extends AppAction with OptimisticCommand { @override Object? optimisticValue() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyValueToState(AppState state, Object? value) => state.copy(isLiked: value as bool); @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { bool isLiked = serverResponse as bool; return state.copy(isLiked: isLiked); } @override Future sendCommandToServer(Object? value) => server.saveLike(value as bool); // If there was an error, reload the value from the database. @override Future reloadFromServer() => server.reload(); @override String toString() => '${super.toString()}(${!state.liked})'; } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( debugShowCheckedModeBanner: false, title: 'OptimisticCommand Mixin Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const MyHomePage(), ), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key}); @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { late Timer _timer; @override void initState() { super.initState(); // Refresh the UI periodically to show the database state. _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { setState(() {}); }); } @override void dispose() { _timer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final isWaiting = context.isWaiting(ToggleLike); return Scaffold( appBar: AppBar(title: const Text('OptimisticCommand Mixin Demo')), body: Column( children: [ // Top half: Like button (Redux state) Expanded( child: Container( color: Colors.blue.shade50, child: Center( child: StoreConnector( converter: (store) => store.state.liked, builder: (context, liked) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'UI State (AsyncRedux)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), Opacity( opacity: isWaiting ? 0.25 : 1.0, child: IconButton( iconSize: 80, icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.red : Colors.grey, ), // // We could also disable the button using isWaiting: // onPressed: isWaiting ? null : () => store.dispatch(ToggleLike()), onPressed: () => store.dispatch(ToggleLike()), ), ), const SizedBox(height: 10), Text( liked ? 'Liked' : 'Not Liked', style: const TextStyle(fontSize: 24), ), const SizedBox(height: 20), Text( context.isWaiting(ToggleLike) ? 'Saving...' : '', style: const TextStyle( fontSize: 14, color: Colors.grey, ), ), Text( 'Button action is aborted while saving', style: const TextStyle( fontSize: 14, color: Colors.grey, ), ), ], ); }, ), ), ), ), // Divider Container( height: 2, color: Colors.grey.shade400, ), // Bottom half: Database state Expanded( child: Container( color: Colors.green.shade50, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'Database State (Simulated)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), Icon( server.databaseLiked ? Icons.favorite : Icons.favorite_border, size: 80, color: server.databaseLiked ? Colors.red : Colors.grey, ), const SizedBox(height: 10), Text( server.databaseLiked ? 'Liked' : 'Not Liked', style: const TextStyle(fontSize: 24), ), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( server.isRequestInProgress ? 'Saving ${server.requestCount}...' : 'Idle', style: TextStyle( fontSize: 16, color: server.isRequestInProgress ? Colors.orange : Colors.grey, fontWeight: server.isRequestInProgress ? FontWeight.bold : FontWeight.normal, ), ), const SizedBox(width: 10), if (server.isRequestInProgress) const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.orange, ), ), ], ), const SizedBox(height: 10), Text( 'Updates after server round-trip (${(server.delayBeforeWrite + server.delayAfterWrite) / 1000}s)', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), const SizedBox(height: 10), Text( 'Number of requests received: ${server.requestCount}', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), const SizedBox(height: 30), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(8), ), child: Column( children: [ const Text( 'Simulate external change to the database:' '\n' '(there is no push)' '\n', style: TextStyle(fontSize: 14, color: Colors.grey), textAlign: TextAlign.center, ), const SizedBox(height: 8), Row( mainAxisSize: MainAxisSize.min, children: [ ElevatedButton.icon( onPressed: () => server.simulateExternalChange(true), icon: const Icon(Icons.favorite, size: 16), label: const Text('Liked'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red.shade100, foregroundColor: Colors.red.shade900, ), ), const SizedBox(width: 16), ElevatedButton.icon( onPressed: () => server.simulateExternalChange(false), icon: const Icon(Icons.favorite_border, size: 16), label: const Text('Not Liked'), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey.shade200, foregroundColor: Colors.grey.shade700, ), ), ], ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: server.isRequestInProgress ? () { server.shouldFail = true; } : null, icon: const Icon(Icons.error_outline, size: 16), label: const Text('Request fails'), style: ElevatedButton.styleFrom( backgroundColor: Colors.orange.shade100, foregroundColor: Colors.orange.shade900, ), ), ], ), ), ], ), ), ), ), ], ), ); } } abstract class AppAction extends ReduxAction {} // //////////////////////////////////////////////////////////////////////////// /// Singleton instance of the simulated server. final server = SimulatedServer(); /// Simulates a remote server with database and request handling. /// All server-side state and behavior is encapsulated here to clearly separate /// it from the local app state managed by Redux. class SimulatedServer { // --------------------------------------------------------------------------- // Server State // --------------------------------------------------------------------------- /// The "database" value stored on the server. bool databaseLiked = false; /// Whether a request is currently being processed. bool isRequestInProgress = false; /// Total number of requests received by the server. int requestCount = 0; /// When true, the next request will fail (for testing error handling). bool shouldFail = false; /// Simulated network delay before writing to database (ms). int delayBeforeWrite = 1500; /// Simulated network delay after writing to database (ms). int delayAfterWrite = 2000; // --------------------------------------------------------------------------- // Server Methods // --------------------------------------------------------------------------- /// Simulates saving to the database. /// Returns the current database value after save completes. Future saveLike(bool flag) async { requestCount++; isRequestInProgress = true; await _interruptibleDelay(delayBeforeWrite); databaseLiked = flag; await _interruptibleDelay(delayAfterWrite); isRequestInProgress = false; // Return the current value in the database. // This may differ from the saved value, simulating server-side logic. return databaseLiked; } /// Simulates reloading the current value from the database. Future reload() async { await Future.delayed(const Duration(milliseconds: 300)); return databaseLiked; } /// Simulates an external change to the database (e.g., from another client). /// Note: OptimisticCommand does not support push notifications. void simulateExternalChange(bool liked) { databaseLiked = liked; } /// Interruptible delay that checks [shouldFail] every 50ms. /// Allows simulating mid-flight request failures. Future _interruptibleDelay(int milliseconds) async { const checkInterval = 50; int remaining = milliseconds; while (remaining > 0) { if (shouldFail) { shouldFail = false; isRequestInProgress = false; throw Exception('Simulated server error'); } final wait = remaining < checkInterval ? remaining : checkInterval; await Future.delayed(Duration(milliseconds: wait)); remaining -= checkInterval; } } } ================================================ FILE: example/lib/main_optimistic_sync.dart ================================================ /// This example is meant to demonstrate the [OptimisticSync] mixin in action. /// The screen is split into two halves: the top shows the UI state (Redux), and /// the bottom shows the simulated database state (server). /// /// ## Use cases to try: /// /// ### 1. Optimistic update /// Tap the heart icon. The UI updates instantly (top half), while the database /// takes ~3.5 seconds to update (bottom half shows "Saving..."). /// /// ### 2. Coalescing (key feature) /// Tap the heart rapidly multiple times while "Saving..." is displayed. Notice: /// - The UI toggles instantly on each tap (always responsive). /// - Only one request is in flight at a time ("Saving 1..."). /// - When the request completes, if the current UI state differs from what was /// sent, a follow-up request is automatically sent ("Saving 2..."). /// - If you toggle an even number of times, no follow-up is needed because the /// final state matches what was originally sent. /// /// ### 3. Button always enabled /// Unlike [OptimisticCommand], the button is never disabled. This allows rapid /// interactions without waiting for server responses. /// /// ### 4. Reload on error /// Tap the heart to start saving. While "Saving..." is displayed, tap "Request /// fails". The UI keeps its optimistic state, but [OptimisticSync.onFinish] is /// called with the error. In this example, we show an error dialog, /// immediately revert to the initial state before the action, and then, /// just to be sure, reload the value from the database. /// /// ### 5. External database changes (no push) /// Use the "Liked" or "Not Liked" buttons at the bottom to change the database /// directly. The UI may update only if a request is still in progress, because /// the request response will overwrite the UI state when it completes. /// But when there is no request in progress, the UI state won't update, /// because [OptimisticSync] doesn't support push notifications. The UI only /// syncs when you tap the heart again. /// /// Note: If you use push, try mixin [OptimisticSyncWithPush] instead. /// import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import "package:meta/meta.dart"; late Store store; void main() { store = Store( initialState: AppState(liked: false), actionObservers: [ConsoleActionObserver()], ); runApp(const MyApp()); } class AppState { final bool liked; AppState({required this.liked}); @useResult AppState copy({bool? isLiked}) => AppState(liked: isLiked ?? this.liked); @override String toString() => 'AppState(liked: $liked)'; } class SetLike extends AppAction { final bool isLiked; SetLike(this.isLiked); @override AppState reduce() => state.copy(isLiked: isLiked); @override String toString() => '${super.toString()}($isLiked)'; } class ToggleLike extends AppAction with OptimisticSync { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState( AppState state, bool optimisticValueToApply) => state.copy(isLiked: optimisticValueToApply); @override AppState applyServerResponseToState(AppState state, Object? serverResponse) { bool isLiked = serverResponse as bool; return state.copy(isLiked: isLiked); } @override Future sendValueToServer(Object? value) => server.saveLike(value as bool); // If there was an error: // 1. Show an error message to the user. // 2. Immediately revert to the initial state before the action. // 3. Then, to be sure, reload the value from the database. @override Future onFinish(Object? error) async { if (error == null) return null; // 1. Show an error message to the user. dispatch( UserExceptionAction('The server request failed', reason: 'The like status was rolled back and then reloaded.'), ); // 2. Immediately rollback to the initial state before the action. dispatchState(state.copy(isLiked: getValueFromState(initialState))); // 3. Then, to be sure, reload the value from the database. bool isLiked = await server.reload(); return state.copy(isLiked: isLiked); } @override String toString() => '${super.toString()}(${!state.liked})'; } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( debugShowCheckedModeBanner: false, title: 'OptimisticSync Mixin Demo', theme: ThemeData(primarySwatch: Colors.blue), home: UserExceptionDialog( child: const MyHomePage(), ), ), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key}); @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { late Timer _timer; @override void initState() { super.initState(); // Refresh the UI periodically to show the database state. _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { setState(() {}); }); } @override void dispose() { _timer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('OptimisticSync Mixin Demo')), body: Column( children: [ // Top half: Like button (Redux state) Expanded( child: Container( color: Colors.blue.shade50, child: Center( child: StoreConnector( converter: (store) => store.state.liked, builder: (context, liked) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'UI State (AsyncRedux)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), IconButton( iconSize: 80, icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.red : Colors.grey, ), onPressed: () { store.dispatch(ToggleLike()); }, ), const SizedBox(height: 10), Text( liked ? 'Liked' : 'Not Liked', style: const TextStyle(fontSize: 24), ), const SizedBox(height: 20), const Text( 'Tap rapidly to see coalescing in action!', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), ], ); }, ), ), ), ), // Divider Container( height: 2, color: Colors.grey.shade400, ), // Bottom half: Database state Expanded( child: Container( color: Colors.green.shade50, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'Database State (Simulated)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), Icon( server.databaseLiked ? Icons.favorite : Icons.favorite_border, size: 80, color: server.databaseLiked ? Colors.red : Colors.grey, ), const SizedBox(height: 10), Text( server.databaseLiked ? 'Liked' : 'Not Liked', style: const TextStyle(fontSize: 24), ), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( server.isRequestInProgress ? 'Saving ${server.requestCount}...' : 'Idle', style: TextStyle( fontSize: 16, color: server.isRequestInProgress ? Colors.orange : Colors.grey, fontWeight: server.isRequestInProgress ? FontWeight.bold : FontWeight.normal, ), ), const SizedBox(width: 10), if (server.isRequestInProgress) const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.orange, ), ), ], ), const SizedBox(height: 10), Text( 'Updates after server round-trip (${(server.delayBeforeWrite + server.delayAfterWrite) / 1000}s)', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), const SizedBox(height: 10), Text( 'Number of requests received: ${server.requestCount}', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), const SizedBox(height: 30), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(8), ), child: Column( children: [ const Text( 'Simulate external change to the database:', style: TextStyle(fontSize: 14, color: Colors.grey), ), const SizedBox(height: 8), Row( mainAxisSize: MainAxisSize.min, children: [ ElevatedButton.icon( onPressed: () => server.simulateExternalChange(true), icon: const Icon(Icons.favorite, size: 16), label: const Text('Liked'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red.shade100, foregroundColor: Colors.red.shade900, ), ), const SizedBox(width: 16), ElevatedButton.icon( onPressed: () => server.simulateExternalChange(false), icon: const Icon(Icons.favorite_border, size: 16), label: const Text('Not Liked'), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey.shade200, foregroundColor: Colors.grey.shade700, ), ), ], ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: server.isRequestInProgress ? () { server.shouldFail = true; } : null, icon: const Icon(Icons.error_outline, size: 16), label: const Text('Request fails'), style: ElevatedButton.styleFrom( backgroundColor: Colors.orange.shade100, foregroundColor: Colors.orange.shade900, ), ), ], ), ), ], ), ), ), ), ], ), ); } } abstract class AppAction extends ReduxAction {} // //////////////////////////////////////////////////////////////////////////// /// Singleton instance of the simulated server. final server = SimulatedServer(); /// Simulates a remote server with database and request handling. /// All server-side state and behavior is encapsulated here to clearly separate /// it from the local app state managed by Redux. class SimulatedServer { // --------------------------------------------------------------------------- // Server State // --------------------------------------------------------------------------- /// The "database" value stored on the server. bool databaseLiked = false; /// Whether a request is currently being processed. bool isRequestInProgress = false; /// Total number of requests received by the server. int requestCount = 0; /// When true, the next request will fail (for testing error handling). bool shouldFail = false; /// Simulated network delay before writing to database (ms). int delayBeforeWrite = 1500; /// Simulated network delay after writing to database (ms). int delayAfterWrite = 2000; // --------------------------------------------------------------------------- // Server Methods // --------------------------------------------------------------------------- /// Simulates saving to the database. /// Returns the current database value after save completes. Future saveLike(bool flag) async { requestCount++; isRequestInProgress = true; await _interruptibleDelay(delayBeforeWrite); databaseLiked = flag; await _interruptibleDelay(delayAfterWrite); isRequestInProgress = false; // Return the current value in the database. // This may differ from the saved value, simulating server-side logic. return databaseLiked; } /// Simulates reloading the current value from the database. Future reload() async { await Future.delayed(const Duration(milliseconds: 500)); return databaseLiked; } /// Simulates an external change to the database (e.g., from another client). void simulateExternalChange(bool liked) { databaseLiked = liked; } /// Interruptible delay that checks [shouldFail] every 50ms. /// Allows simulating mid-flight request failures. Future _interruptibleDelay(int milliseconds) async { const checkInterval = 50; int remaining = milliseconds; while (remaining > 0) { if (shouldFail) { shouldFail = false; isRequestInProgress = false; throw Exception('Simulated server error'); } final wait = remaining < checkInterval ? remaining : checkInterval; await Future.delayed(Duration(milliseconds: wait)); remaining -= checkInterval; } } } ================================================ FILE: example/lib/main_optimistic_sync_with_push.dart ================================================ /// This example is meant to demonstrate the [OptimisticSyncWithPush] mixin in /// action. The screen is split into two halves: the top shows the UI state /// (Redux), and the bottom shows the simulated database state (server). /// /// ## Use cases to try: /// /// ### 1. Optimistic update /// Tap the heart icon. The UI updates instantly (top half), while the database /// takes ~3.5 seconds to update (bottom half shows "Saving..."). /// /// ### 2. Coalescing /// Tap the heart rapidly multiple times while "Saving..." is displayed. Notice: /// - The UI toggles instantly on each tap (always responsive). /// - Only one request is in flight at a time ("Saving 1..."). /// - When the request completes, if the current UI state differs from what was /// sent, a follow-up request is automatically sent ("Saving 2..."). /// /// ### 3. Push updates (key feature) /// With "Push database changes" switch ON (default), tap "Liked" or "Not Liked" /// buttons to simulate an external change from another device. The UI updates /// immediately via the simulated WebSocket push. This is the key difference /// from [OptimisticSync], which doesn't support push. /// /// ### 4. Push disabled behavior /// Turn OFF the "Push database changes" switch, then tap "Liked" or "Not Liked". /// The database changes but the UI doesn't update (no push). The UI only syncs /// when you tap the heart again. /// /// ### 5. Push during in-flight request /// With push ON, tap the heart to start saving. While "Saving..." is displayed, /// tap "Liked" or "Not Liked" to simulate an external change. Notice how the /// mixin handles the race condition using revision tracking, ensuring eventual /// consistency. /// /// ### 6. Reload on error /// Tap the heart to start saving. While "Saving..." is displayed, tap "Request /// fails". The UI keeps its optimistic state, but [OptimisticSyncWithPush.onFinish] /// is called with the error. In this example, we reload from the database /// to restore the correct state. /// /// ### 7. Persistence /// Close and restart the app. The last known state is persisted using /// shared_preferences (see class [MyPersistor] below) and restored on startup. /// When using PUSH, we must persist the server revision as well to ensure /// correct operation across app restarts. /// /// Note: If you DO NOT use push, try mixins [OptimisticSync] or /// [OptimisticCommand] instead. They are much easier to implement since they /// don't require revision tracking. /// import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:async_redux/async_redux.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:shared_preferences/shared_preferences.dart'; late Store store; late MyPersistor persistor; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Create the persistor. persistor = MyPersistor(); // Load persisted state. var initialState = await persistor.readState(); // If no persisted state exists, create the default initial state and save it. if (initialState == null) { initialState = AppState(liked: false); await persistor.saveInitialState(initialState); } // Initialize the server SIMULATION, by setting the like and revision counter. // In production, this would be the real server, using a database. // The key is (ToggleLike, null) as computed by computeOptimisticSyncKey(). server.revisionCounter = initialState.getServerRevision((ToggleLike, null)); server.databaseLiked = initialState.liked; store = Store( initialState: initialState, actionObservers: [ConsoleActionObserver()], persistor: persistor, ); runApp(const MyApp()); } class AppState { final bool liked; /// Stores the last known server revision for each [OptimisticSyncWithPush] /// action. Keys are stringified versions of action keys (e.g., /// "(ToggleLike, null)"). It's persisted with [MyPersistor] to maintain /// correct operation across app restarts. The mixin uses these revisions to /// detect stale push updates and ensure eventual consistency. final IMap serverRevisionMap; AppState({required this.liked, IMap? serverRevisionMap}) : serverRevisionMap = serverRevisionMap ?? const IMapConst({}); @useResult AppState copy({bool? isLiked, IMap? serverRevisionMap}) => AppState( liked: isLiked ?? this.liked, serverRevisionMap: serverRevisionMap ?? this.serverRevisionMap, ); /// Returns a copy of the state with the server revision updated for the given key. @useResult AppState withServerRevision(Object? key, int revision) => copy( serverRevisionMap: serverRevisionMap.add( _keyToString(key), revision, ), ); /// Returns the server revision for the given key, or -1 if not found. int getServerRevision(Object? key) => serverRevisionMap.get(_keyToString(key)) ?? -1; Map toJson() => { 'liked': liked, 'serverRevisionMap': serverRevisionMap.unlock, }; factory AppState.fromJson(Map json) => AppState( liked: json['liked'] as bool? ?? false, serverRevisionMap: IMap.fromEntries( (json['serverRevisionMap'] as Map? ?? {}) .entries .map((e) => MapEntry(e.key, e.value as int)), ), ); @override String toString() => 'AppState(liked: $liked, serverRevisionMap: $serverRevisionMap)'; } /// Converts an action key to a String for persistence. /// The key is typically the runtimeType of the action, or a custom identifier for keyed actions. String _keyToString(Object? key) => key?.toString() ?? '_default_'; /// Persistor that saves AppState to shared_preferences. class MyPersistor extends Persistor { static const _key = 'app_state'; @override Future readState() async { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_key); if (jsonString == null) return null; try { final json = jsonDecode(jsonString) as Map; print('Loaded AppState from prefs: $json'); return AppState.fromJson(json); } catch (e) { return null; } } @override Future deleteState() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_key); } @override Future persistDifference({ required AppState? lastPersistedState, required AppState newState, }) async { final prefs = await SharedPreferences.getInstance(); final json = jsonEncode(newState.toJson()); await prefs.setString(_key, json); } /// Short throttle for this demo to save changes quickly. @override Duration? get throttle => const Duration(milliseconds: 300); } /// Represents the server's response including the revision number. class ServerResponse { final bool liked; final int serverRevision; final int localRevision; final int deviceId; ServerResponse({ required this.liked, required this.serverRevision, required this.localRevision, required this.deviceId, }); } /// ServerPush action for handling WebSocket push updates. /// This action properly integrates with [OptimisticSyncWithPush]. class PushLikeUpdate extends AppAction with ServerPush { final bool liked; final int serverRev; final int localRev; final int deviceId; PushLikeUpdate({ required this.liked, required this.serverRev, required this.localRev, required this.deviceId, }); /// Return the Type of the associated OptimisticSyncWithPush action. @override Type associatedAction() => ToggleLike; @override PushMetadata pushMetadata() { print('Incoming metadata: ${( serverRevision: serverRev, localRevision: localRev, deviceId: deviceId, )}'); return ( serverRevision: serverRev, localRevision: localRev, deviceId: deviceId, ); } /// Apply the pushed data to state and save the revision. @override AppState? applyServerPushToState( AppState state, Object? key, int serverRevision, ) => state.copy(isLiked: liked).withServerRevision(key, serverRevision); /// Return the current server revision from state for this key. @override int getServerRevisionFromState(Object? key) => state.getServerRevision(key); @override String toString() => '${super.toString()}(liked: $liked, serverRev: $serverRev)'; } class ToggleLike extends AppAction with OptimisticSyncWithPush { // Store the server revision from the response. int _serverRevFromResponse = 0; @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState( AppState state, bool optimisticValueToApply, ) => state.copy(isLiked: optimisticValueToApply); @override AppState? applyServerResponseToState(AppState state, Object? serverResponse) { // Apply both the liked value and the server revision. // Use computeOptimisticSyncKey() to get the same key used by the mixin. return state .copy(isLiked: serverResponse as bool) .withServerRevision(computeOptimisticSyncKey(), _serverRevFromResponse); } @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { print('Sending to server: $optimisticValue'); // Send to server and get response with revision. final response = await server.saveLike( optimisticValue as bool, localRevision, deviceId, ); // Store the server revision for use in applyServerResponseToState. print('Server response: $response'); // Inform the mixin about the server revision. informServerRevision(response.serverRevision); return response.liked; } /// Return the current server revision from state. @override int getServerRevisionFromState(Object? key) => state.getServerRevision(key); // If there was an error, revert the state to the database value. @override Future onFinish(Object? error) async { if (error == null) return null; // If there was an error, reload the value from the database. bool isLiked = await server.reload(); return state.copy(isLiked: isLiked); } @override String toString() => '${super.toString()}(${!state.liked})'; } /// Resets all state: deletes persisted state and resets server simulation. class ResetAllState extends AppAction { @override Future reduce() async { // Delete persisted state. await persistor.deleteState(); // Reset server simulation. server.reset(); // Return fresh initial state. return AppState(liked: false); } } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( debugShowCheckedModeBanner: false, title: 'OptimisticSyncWithPush Mixin Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const MyHomePage(), ), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key}); @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { late Timer _timer; @override void initState() { super.initState(); // Refresh the UI periodically to show the database state. _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { setState(() {}); }); } @override void dispose() { _timer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('OptimisticSyncWithPush Mixin Demo'), actions: [ IconButton( icon: const Icon(Icons.delete_outline, size: 20), tooltip: 'Reset all state', onPressed: () => store.dispatch(ResetAllState()), ), ], ), body: Column( children: [ // Top half: Like button (Redux state) Expanded( child: Container( color: Colors.blue.shade50, child: Center( child: StoreConnector( converter: (store) => store.state.liked, builder: (context, liked) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'UI State (AsyncRedux)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), IconButton( iconSize: 80, icon: Icon( liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.red : Colors.grey, ), onPressed: () { store.dispatch(ToggleLike()); }, ), const SizedBox(height: 10), Text( liked ? 'Liked' : 'Not Liked', style: const TextStyle(fontSize: 24), ), const SizedBox(height: 20), const Text( 'Tap rapidly to see coalescing in action!', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), ], ); }, ), ), ), ), // Divider Container( height: 2, color: Colors.grey.shade400, ), // Bottom half: Database state Expanded( child: Container( color: Colors.green.shade50, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'Database State (Simulated)', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), const SizedBox(height: 20), Icon( server.databaseLiked ? Icons.favorite : Icons.favorite_border, size: 80, color: server.databaseLiked ? Colors.red : Colors.grey, ), const SizedBox(height: 10), Text( server.databaseLiked ? 'Liked' : 'Not Liked', style: const TextStyle(fontSize: 24), ), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( server.isRequestInProgress ? 'Saving ${server.requestCount}...' : 'Idle', style: TextStyle( fontSize: 16, color: server.isRequestInProgress ? Colors.orange : Colors.grey, fontWeight: server.isRequestInProgress ? FontWeight.bold : FontWeight.normal, ), ), const SizedBox(width: 10), if (server.isRequestInProgress) const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.orange, ), ), ], ), const SizedBox(height: 10), Text( 'Updates after server round-trip (${(server.delayBeforeWrite + server.delayAfterWrite) / 1000}s)', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), const SizedBox(height: 10), Text( 'Number of requests received: ${server.requestCount}', style: TextStyle( fontSize: 14, color: Colors.grey, ), ), const SizedBox(height: 30), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade400), borderRadius: BorderRadius.circular(8), ), child: Column( children: [ const Text( 'Simulate external change to the database:', style: TextStyle(fontSize: 14, color: Colors.grey), ), const SizedBox(height: 8), Row( mainAxisSize: MainAxisSize.min, children: [ ElevatedButton.icon( onPressed: () => server.simulateExternalChange(true), icon: const Icon(Icons.favorite, size: 16), label: const Text('Liked'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red.shade100, foregroundColor: Colors.red.shade900, ), ), const SizedBox(width: 16), ElevatedButton.icon( onPressed: () => server.simulateExternalChange(false), icon: const Icon(Icons.favorite_border, size: 16), label: const Text('Not Liked'), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey.shade200, foregroundColor: Colors.grey.shade700, ), ), ], ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: server.isRequestInProgress ? () { server.shouldFail = true; } : null, icon: const Icon(Icons.error_outline, size: 16), label: const Text('Request fails'), style: ElevatedButton.styleFrom( backgroundColor: Colors.orange.shade100, foregroundColor: Colors.orange.shade900, ), ), const SizedBox(height: 12), Row( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Push database changes', style: TextStyle(fontSize: 14), ), const SizedBox(width: 8), Switch( value: server.websocketPushEnabled, onChanged: (value) { setState(() { server.websocketPushEnabled = value; }); }, ), ], ), ], ), ), ], ), ), ), ), ], ), ); } } abstract class AppAction extends ReduxAction {} // //////////////////////////////////////////////////////////////////////////// /// Singleton instance of the simulated server. final server = SimulatedServer(); /// Simulates a remote server with database, WebSocket push, and request handling. /// All server-side state and behavior is encapsulated here to clearly separate /// it from the local app state managed by Redux. class SimulatedServer { // --------------------------------------------------------------------------- // Server State // --------------------------------------------------------------------------- /// The "database" value stored on the server. bool databaseLiked = false; /// Whether a request is currently being processed. bool isRequestInProgress = false; /// Total number of requests received by the server. int requestCount = 0; /// When true, the next request will fail (for testing error handling). bool shouldFail = false; /// Whether the server should push changes via "WebSocket" after writes. bool websocketPushEnabled = true; /// Server-side revision counter. Incremented on each successful write. /// In production, this would be managed by the actual server/database. int revisionCounter = 0; /// Simulated network delay before writing to database (ms). int delayBeforeWrite = 1500; /// Simulated network delay after writing to database (ms). int delayAfterWrite = 2000; // --------------------------------------------------------------------------- // Server Methods // --------------------------------------------------------------------------- /// Simulates saving to the database. /// Returns a [ServerResponse] with the current liked value and server revision. Future saveLike( bool flag, int localRevision, int deviceId, ) async { print('Save started'); requestCount++; isRequestInProgress = true; print('flag = $flag, localRev = $localRevision, deviceId = $deviceId'); await _interruptibleDelay(delayBeforeWrite); // Save flag and increment server revision (simulate server-side versioning). databaseLiked = flag; revisionCounter++; final currentServerRev = revisionCounter; print( 'flag = $flag, serverRev = $currentServerRev, localRev = $localRevision, deviceId = $deviceId'); if (websocketPushEnabled) push( isLiked: flag, serverRev: currentServerRev, localRev: localRevision, deviceId: deviceId, ); await _interruptibleDelay(delayAfterWrite); isRequestInProgress = false; print('flag = $flag, serverRev = $currentServerRev'); print('Save ended'); return ServerResponse( liked: databaseLiked, serverRevision: currentServerRev, localRevision: localRevision, deviceId: deviceId, ); } /// Simulates reloading the current value from the database. Future reload() async { await Future.delayed(const Duration(milliseconds: 300)); return databaseLiked; } /// Simulates a WebSocket push from the server to the client. Future push({ required bool isLiked, required int serverRev, required int localRev, required int deviceId, }) async { await Future.delayed(const Duration(milliseconds: 50)); store.dispatch(PushLikeUpdate( liked: isLiked, serverRev: serverRev, localRev: localRev, deviceId: deviceId, )); } /// Simulates an external change to the database (e.g., from another client). void simulateExternalChange(bool liked) { databaseLiked = liked; if (websocketPushEnabled) { revisionCounter++; push( isLiked: databaseLiked, serverRev: revisionCounter, localRev: Random().nextInt(4294967296), deviceId: Random().nextInt(4294967296), ); } } /// Resets the server to its initial state. void reset() { databaseLiked = false; isRequestInProgress = false; requestCount = 0; shouldFail = false; revisionCounter = 0; } /// Interruptible delay that checks [shouldFail] every 50ms. /// Allows simulating mid-flight request failures. Future _interruptibleDelay(int milliseconds) async { const checkInterval = 50; int remaining = milliseconds; while (remaining > 0) { if (shouldFail) { shouldFail = false; isRequestInProgress = false; throw Exception('Simulated server error'); } final wait = remaining < checkInterval ? remaining : checkInterval; await Future.delayed(Duration(milliseconds: wait)); remaining -= checkInterval; } } } ================================================ FILE: example/lib/main_polling.dart ================================================ import 'dart:math'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example demonstrates the [Polling] mixin with all [Poll] enum values: /// /// - [Poll.start] — Starts polling and runs reduce immediately. If polling is /// already active, does nothing. /// /// - [Poll.stop] — Cancels the timer and skips reduce. /// /// - [Poll.runNowAndRestart] — Runs reduce immediately and restarts the timer /// from that moment. If polling is not active, behaves like [Poll.start]. /// /// - [Poll.once] — Runs reduce immediately without starting, stopping, or /// restarting polling. /// /// The app simulates polling a stock price every 3 seconds. Four buttons /// demonstrate each [Poll] value, and the UI shows the current price, /// how many times it has been fetched, and whether polling is active. /// void main() { store = Store(initialState: AppState.initialState()); runApp(MyApp()); } // ============================================================================= // State // ============================================================================= @immutable class AppState { final double price; final int fetchCount; final bool isPolling; AppState({ required this.price, required this.fetchCount, required this.isPolling, }); AppState copy({double? price, int? fetchCount, bool? isPolling}) => AppState( price: price ?? this.price, fetchCount: fetchCount ?? this.fetchCount, isPolling: isPolling ?? this.isPolling, ); static AppState initialState() => AppState( price: 100.0, fetchCount: 0, isPolling: false, ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && price == other.price && fetchCount == other.fetchCount && isPolling == other.isPolling; @override int get hashCode => price.hashCode ^ fetchCount.hashCode ^ isPolling.hashCode; } // ============================================================================= // App // ============================================================================= class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), ), ); } // ============================================================================= // Actions // ============================================================================= /// A polling action that simulates fetching a stock price. /// /// This uses "Option 1" (single action for everything): the same action class /// both controls polling and does the work. The [createPollingAction] returns /// the same action type with [Poll.once], so timer ticks run reduce without /// restarting the timer. class PollStockPriceAction extends ReduxAction with Polling { @override final Poll poll; PollStockPriceAction({this.poll = Poll.once}); @override Duration get pollInterval => const Duration(seconds: 3); @override ReduxAction createPollingAction() => PollStockPriceAction(poll: Poll.once); /// Update the [isPolling] flag in state whenever the polling status changes. @override void before() { switch (poll) { case Poll.start: case Poll.runNowAndRestart: dispatch(SetPollingFlagAction(true)); case Poll.stop: dispatch(SetPollingFlagAction(false)); case Poll.once: break; } } @override AppState reduce() { // Simulate a price change: random walk around the current price. final random = Random(); final change = (random.nextDouble() - 0.5) * 4; // -2.0 to +2.0 final newPrice = (state.price + change).clamp(50.0, 200.0); return state.copy( price: double.parse(newPrice.toStringAsFixed(2)), fetchCount: state.fetchCount + 1, ); } } /// Marks polling as active or inactive in the state (so the UI can reflect it). class SetPollingFlagAction extends ReduxAction { final bool isPolling; SetPollingFlagAction(this.isPolling); @override AppState reduce() => state.copy(isPolling: isPolling); } // ============================================================================= // Home page // ============================================================================= class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { var price = context.select((AppState s) => s.price); var fetchCount = context.select((AppState s) => s.fetchCount); var isPolling = context.select((AppState s) => s.isPolling); return Scaffold( appBar: AppBar(title: const Text('Polling Mixin Example')), body: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Price display Card( child: Padding( padding: const EdgeInsets.all(24), child: Column( children: [ const Text('Stock Price', style: TextStyle(fontSize: 16, color: Colors.grey)), const SizedBox(height: 8), Text( '\$${price.toStringAsFixed(2)}', style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text('Fetched $fetchCount time${fetchCount == 1 ? '' : 's'}'), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( isPolling ? Icons.sync : Icons.sync_disabled, color: isPolling ? Colors.green : Colors.grey, ), const SizedBox(width: 8), Text( isPolling ? 'Polling active (every 3s)' : 'Polling inactive', style: TextStyle(color: isPolling ? Colors.green : Colors.grey), ), ], ), ], ), ), ), const SizedBox(height: 24), const Text('Poll values:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 12), // Poll.start ElevatedButton.icon( icon: const Icon(Icons.play_arrow), label: const Text('Poll.start'), style: ElevatedButton.styleFrom(backgroundColor: Colors.green), onPressed: () => dispatch(PollStockPriceAction(poll: Poll.start)), ), const Padding( padding: EdgeInsets.only(left: 16, bottom: 12, top: 4), child: Text( 'Starts polling and runs reduce immediately. ' 'If polling is already active, does nothing.', style: TextStyle(fontSize: 12, color: Colors.grey), ), ), // Poll.stop ElevatedButton.icon( icon: const Icon(Icons.stop), label: const Text('Poll.stop'), style: ElevatedButton.styleFrom(backgroundColor: Colors.red), onPressed: () => dispatch(PollStockPriceAction(poll: Poll.stop)), ), const Padding( padding: EdgeInsets.only(left: 16, bottom: 12, top: 4), child: Text( 'Cancels the timer and skips reduce.', style: TextStyle(fontSize: 12, color: Colors.grey), ), ), // Poll.runNowAndRestart ElevatedButton.icon( icon: const Icon(Icons.refresh), label: const Text('Poll.runNowAndRestart'), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), onPressed: () => dispatch(PollStockPriceAction(poll: Poll.runNowAndRestart)), ), const Padding( padding: EdgeInsets.only(left: 16, bottom: 12, top: 4), child: Text( 'Runs reduce immediately and restarts the timer from now. ' 'If not active, behaves like Poll.start.', style: TextStyle(fontSize: 12, color: Colors.grey), ), ), // Poll.once ElevatedButton.icon( icon: const Icon(Icons.looks_one), label: const Text('Poll.once'), style: ElevatedButton.styleFrom(backgroundColor: Colors.blue), onPressed: () => dispatch(PollStockPriceAction(poll: Poll.once)), ), const Padding( padding: EdgeInsets.only(left: 16, bottom: 12, top: 4), child: Text( 'Runs reduce immediately without starting, stopping, ' 'or restarting polling.', style: TextStyle(fontSize: 12, color: Colors.grey), ), ), ], ), ), ); } } // ============================================================================= // BuildContext extension // ============================================================================= extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); } ================================================ FILE: example/lib/main_select.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a counter, a text character, and a button. /// When the button is tapped, the counter will increment synchronously, /// while an async process downloads some text character that relates /// to the counter number (using the Star Wars API: https://swapi.dev). /// /// If there is no internet connection, it will display a dialog to the /// user, saying: "There is no Internet". This is implemented with mixin /// `CheckInternet` added to action `IncrementAndGetDescriptionAction`, /// and a `UserExceptionDialog` added below `MaterialApp`. /// /// Open the console to see when each widget rebuilds. Here are the 4 widgets: /// /// 1. MyHomePage (red): rebuilds only during the initial build. /// /// 2. CounterWidget (blue): rebuilds when you press the `+` button. /// /// 3. DescriptionWidget (yellow): rebuilds only when the character loads. /// /// 4. LoadingStatusWidget (grey): rebuilds when [IncrementAndGetDescriptionAction] /// is dispatched, and when it finishes (either successfully or with error). /// /// It should start like this: /// /// ``` /// Restarted application in 271ms. /// 🔴 MyHomePage rebuilt /// 🔵 CounterWidget rebuilt /// 💛 DescriptionWidget rebuilt /// 🍏 LoadingStatusWidget rebuilt /// 🔴 MyHomePage rebuilt /// 🔵 CounterWidget rebuilt /// 💛 DescriptionWidget rebuilt /// 🍏 LoadingStatusWidget rebuilt /// ``` /// /// When you press the `+` button, you should immediately see these extra lines: /// ``` /// 🍏 LoadingStatusWidget rebuilt /// 🔵 CounterWidget rebuilt /// ``` /// /// And then, a moment later, when the character loads: /// /// ``` /// 🍏 LoadingStatusWidget rebuilt /// 💛 DescriptionWidget rebuilt /// ``` /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter and a character. @immutable class AppState { final int counter; final String character; AppState({ required this.counter, required this.character, }); AppState copy({int? counter, String? character}) => AppState( counter: counter ?? this.counter, character: character ?? this.character, ); static AppState initialState() => AppState(counter: 0, character: ""); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && character == other.character; @override int get hashCode => counter.hashCode ^ character.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: UserExceptionDialog( child: MyHomePage(), ), ), ); } /// This action increments the counter by 1, /// and then gets some character text relating to the new counter number. class IncrementAndGetDescriptionAction extends ReduxAction with CheckInternet { // // Async reducer. // To make it async we simply return Future instead of AppState. @override Future reduce() async { // First, we increment the counter, synchronously. dispatch(IncrementAction(amount: 1)); // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String character = json['name'] ?? 'Unknown character'; // After we get the response, we can modify the state with it, // without having to dispatch another action. return state.copy(character: character); } @override Object? wrapError(error, StackTrace stackTrace) { print('Error in IncrementAndGetDescriptionAction: $error'); return (error is UserException) ? error : const UserException('Failed to load.'); } } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); // Synchronous reducer. @override AppState reduce() => state.copy(counter: state.counter + amount); } /// This is a "smart-widget" that directly accesses the store to dispatch actions. /// It uses extracted widgets (CounterWidget and DescriptionWidget) that each /// independently select their own state and rebuild only when needed. class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🔴 MyHomePage rebuilt'); return Scaffold( appBar: AppBar(title: const Text('Star Wars Character Example')), body: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CounterWidget(), DescriptionWidget(), LoadingStatusWidget(), ], ), ), floatingActionButton: FloatingActionButton( // Dispatch action directly from widget onPressed: () => dispatch(IncrementAndGetDescriptionAction()), child: const Icon(Icons.add), ), ); } } /// Widget that selects and displays ONLY the counter. /// Rebuilds ONLY when the counter changes, not when character changes. class CounterWidget extends StatelessWidget { const CounterWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🔵 CounterWidget rebuilt'); // Select only counter. Rebuilds only when counter changes. final counter = context.select((st) => st.counter); return Column( children: [ const Text('Star Wars character for counter:'), Text('$counter', style: const TextStyle(fontSize: 30)), ], ); } } /// Widget that selects and displays ONLY the character. /// Rebuilds ONLY when the character changes, not when counter changes. class DescriptionWidget extends StatelessWidget { const DescriptionWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('💛 DescriptionWidget rebuilt'); return Text( context.select((st) => st.character), style: const TextStyle(fontSize: 15, color: Colors.black), textAlign: TextAlign.center, ); } } /// Widget that selects and displays ONLY the character. /// Rebuilds ONLY when the character changes, not when counter changes. class LoadingStatusWidget extends StatelessWidget { const LoadingStatusWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🍏 LoadingStatusWidget rebuilt'); bool isWaiting = context.isWaiting(IncrementAndGetDescriptionAction); bool isFailed = context.isFailed(IncrementAndGetDescriptionAction); return Text( isFailed ? 'Error loading character!' : isWaiting ? 'Loading character...' : '', style: const TextStyle(fontSize: 15, color: Colors.grey), textAlign: TextAlign.center, ); } } /// Recommended to create this extension. extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_select_2.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Recommended to create this extension. extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); } void main() { final store = Store(initialState: AppState.initialState()); runApp(MyApp(store: store)); } class MyApp extends StatelessWidget { final Store store; const MyApp({Key? key, required this.store}) : super(key: key); @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( debugShowCheckedModeBanner: false, title: 'Select Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const MainScreen(), ), ); } } class MainScreen extends StatelessWidget { const MainScreen({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('AsyncRedux Select Demo'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Display widgets const Card( child: Padding( padding: EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Widget States:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), SizedBox(height: 16), ContextStateWidget(), SizedBox(height: 8), ContextReadWidget(), SizedBox(height: 8), SelectDateWidget(), SizedBox(height: 8), SelectFlagWidget(), ], ), ), ), const SizedBox(height: 24), // Control buttons const Text( 'Actions:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: () => context.dispatch(IncrementNumberAction()), icon: const Icon(Icons.add), label: const Text('Increment Number'), ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () => context.dispatch(AddXToTextAction()), icon: const Icon(Icons.text_fields), label: const Text('Add X to Text'), ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () => context.dispatch(AddDayToDateAction()), icon: const Icon(Icons.calendar_today), label: const Text('Add Day to Date'), ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () => context.dispatch(ToggleFlagAction()), icon: const Icon(Icons.flag), label: const Text('Toggle Flag'), ), ], ), ), ); } } /// WIDGET 1: Uses `context.state` which uses `getState()`. /// This widget rebuilds on ANY state change. /// class ContextStateWidget extends StatelessWidget { const ContextStateWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🔴 ContextStateWidget rebuilt'); // Will rebuild automatically on ANY state changes. var state = context.state; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red.shade200), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '1. getState (notify: true)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red), ), Text('Number: ${state.number}'), Text('Text: ${state.text}'), Text('Date: ${state.date.toString().split(' ')[0]}'), Text('Flag: ${state.flag}'), const Text( 'Rebuilds on ANY change', style: TextStyle(fontSize: 11, fontStyle: FontStyle.italic), ), ], ), ); } } /// WIDGET 2: Uses `context.read()` which uses `getRead()`. /// This widget does NOT rebuild on ANY state change. /// class ContextReadWidget extends StatelessWidget { const ContextReadWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🟡 ContextReadWidget rebuilt'); // It will NEVER rebuild automatically on state changes. final state = context.read(); return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.yellow.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.yellow.shade700), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '2. getState (notify: false)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange), ), Text('Number: ${state.number}'), Text('Text: ${state.text}'), Text('Date: ${state.date.toString().split(' ')[0]}'), Text('Flag: ${state.flag}'), const Text( 'Never rebuilds (shows initial state)', style: TextStyle(fontSize: 11, fontStyle: FontStyle.italic), ), ], ), ); } } /// WIDGET 3: Uses `context.select()` which uses `getSelect()`. /// This widget rebuilds ONLY when the selected part of the state changes. /// class SelectDateWidget extends StatelessWidget { const SelectDateWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🟢 SelectDateWidget rebuilt'); // Will rebuild automatically ONLY when `state.date` changes. // The return type (DateTime) is automatically inferred! final date = context.select((st) => st.date); return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.green.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.green.shade400), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '3. select (date only)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green), ), Text('Date: ${date.toString().split(' ')[0]}'), const Text( 'Only rebuilds when date changes', style: TextStyle(fontSize: 11, fontStyle: FontStyle.italic), ), ], ), ); } } /// WIDGET 3: Uses `context.select()` which uses `getSelect()`. /// This widget rebuilds ONLY when the selected part of the state changes. /// class SelectFlagWidget extends StatelessWidget { const SelectFlagWidget({Key? key}) : super(key: key); @override Widget build(BuildContext context) { print('🔵 SelectFlagWidget rebuilt'); // Will rebuild automatically ONLY when `state.flag` changes. // The return type (bool) is automatically inferred! final flag = context.select((st) => st.flag); return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.blue.shade50, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.blue.shade400), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '4. select (flag only)', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue), ), Text('Flag: $flag'), const Text( 'Only rebuilds when flag changes', style: TextStyle(fontSize: 11, fontStyle: FontStyle.italic), ), ], ), ); } } class AppState { final int number; final String text; final DateTime date; final bool flag; AppState({ required this.number, required this.text, required this.date, required this.flag, }); static AppState initialState() => AppState( number: 0, text: 'Hello', date: DateTime(2024, 1, 1), flag: false, ); AppState copyWith({ int? number, String? text, DateTime? date, bool? flag, }) { return AppState( number: number ?? this.number, text: text ?? this.text, date: date ?? this.date, flag: flag ?? this.flag, ); } @override String toString() => 'AppState(number: $number, text: $text, date: $date, flag: $flag)'; } class IncrementNumberAction extends ReduxAction { @override AppState reduce() { return state.copyWith(number: state.number + 1); } } class AddXToTextAction extends ReduxAction { @override AppState reduce() { return state.copyWith(text: state.text + 'X'); } } class AddDayToDateAction extends ReduxAction { @override AppState reduce() { return state.copyWith(date: state.date.add(const Duration(days: 1))); } } class ToggleFlagAction extends ReduxAction { @override AppState reduce() { return state.copyWith(flag: !state.flag); } } //////////////////////////////////////////////////////////////////////////////// // USAGE NOTES /* This example shows the difference between various state access methods: 1. **ContextStateWidget** (Red): - Uses: `context.state` (via extension) - Rebuilds on ANY state change - Shows all state values - Watch the console: prints on every action 2. **ContextReadWidget** (Yellow): - Uses: `context.read()` (via extension) - NEVER rebuilds automatically - Always shows initial state values - Only prints once during initial build 3. **SelectDateWidget** (Green): - Uses: `context.select((state) => state.date)` (via extension, type inferred) - Only rebuilds when date changes - Only shows date value - Watch the console: only prints when "Add Day" is pressed 4. **SelectFlagWidget** (Blue): - Uses: `context.select((state) => state.flag)` (via extension, type inferred) - Only rebuilds when flag changes - Only shows flag value - Watch the console: only prints when "Toggle Flag" is pressed Run the app and watch the console output to see which widgets rebuild! Expected behavior: - Press "Increment Number": Only red widget rebuilds - Press "Add X to Text": Only red widget rebuilds - Press "Add Day to Date": Red and green widgets rebuild - Press "Toggle Flag": Red and blue widgets rebuild */ ================================================ FILE: example/lib/main_show_error_dialog.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; /// This example lets you enter a name and click save. /// If the name has less than 4 chars, an error dialog will be shown. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is the user name. @immutable class AppState { final String? name; AppState({this.name}); AppState copy({String? name}) => AppState(name: name ?? this.name); static AppState initialState() => AppState(name: ""); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && name == other.name; @override int get hashCode => name.hashCode; } /// To display errors, put the [UserExceptionDialog] below [StoreProvider] and [MaterialApp]. class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: UserExceptionDialog( child: MyHomePage(), ), ), ); } class SaveUserAction extends ReduxAction { final String name; SaveUserAction(this.name); @override AppState reduce() { print("Saving '$name'."); if (name.length < 4) throw const UserException("Name needs 4 letters or more.", errorText: 'At least 4 letters.'); return state.copy(name: name); } @override Object wrapError(error, stackTrace) => // const UserException("Save failed") .addCause(error) .addCallbacks(onOk: () => print("Dialog was dismissed.")); // Note we could also have a CANCEL button here: // .addCallbacks(onOk: ..., onCancel: () => print("CANCEL pressed, or dialog dismissed.")); } class MyHomePage extends StatefulWidget { MyHomePage({Key? key}) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { TextEditingController? controller; @override void initState() { super.initState(); controller = TextEditingController(); } @override Widget build(BuildContext context) { // Use context.select to get the name from the state var name = context.select((AppState state) => state.name); return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Show Error Dialog Example')), body: Center( child: Padding( padding: const EdgeInsets.all(12.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'Type a name and save:\n(See error if less than 4 chars)', textAlign: TextAlign.center), // TextField( controller: controller, onChanged: (text) { // This is optional, as the exception is already cleared when the // action dispatches again. Comment it out to see the difference. if (text.length >= 4) context.clearExceptionFor(SaveUserAction); }, onSubmitted: (String text) => dispatch(SaveUserAction(text)), ), const SizedBox(height: 30), // // If the save failed, show the error message in red text. if (context.isFailed(SaveUserAction)) Text( context.exceptionFor(SaveUserAction)?.errorText ?? '', style: const TextStyle(color: Colors.red), ), // Text('Current Name: $name'), ], ), ), ), floatingActionButton: FloatingActionButton( onPressed: () => dispatch(SaveUserAction(controller!.text)), child: const Text("Save"), ), ), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_show_spinner.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; /// This example shows a counter and a button. /// When the button is tapped, the counter will increment asynchronously. void main() { store = Store(initialState: AppState(counter: 0, something: 0)); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: const MaterialApp(home: HomePage()), ); } class HomePage extends StatelessWidget { const HomePage({ super.key, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Show Spinner Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), CounterWidget(), ], ), ), // Here we disable the button while the `WaitAndIncrementAction` action is running. floatingActionButton: context.isWaiting(WaitAndIncrementAction) ? const FloatingActionButton( disabledElevation: 0, onPressed: null, child: SizedBox(width: 25, height: 25, child: CircularProgressIndicator())) : FloatingActionButton( disabledElevation: 0, onPressed: () => dispatch(WaitAndIncrementAction()), child: const Icon(Icons.add), ), ); } } /// This action waits for 2 seconds, then increments the counter by 1. class WaitAndIncrementAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 2)); return AppState( counter: state.counter + 1, something: state.something, ); } } class CounterWidget extends StatelessWidget { @override Widget build(BuildContext context) { var _isWaiting = context.isWaiting(WaitAndIncrementAction); return Text( '${context.state.counter}', style: TextStyle(fontSize: 40, color: _isWaiting ? Colors.grey[350] : Colors.black), ); } } extension _BuildContextExtension on BuildContext { AppState get state => getState(); } class AppState { int counter; int something; AppState({ required this.counter, required this.something, }); @override String toString() => 'AppState{counter: $counter}'; @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter; @override int get hashCode => counter.hashCode; } ================================================ FILE: example/lib/main_wait_action_advanced_1.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows how to use [WaitAction] in advanced ways. /// For this to work, the [AppState] must have a [wait] field of type [Wait], /// and this field must be in the [AppState.copy] method as a named parameter. /// /// 10 buttons are shown. When a button is clicked it will be /// replaced by a downloaded text description. Each button shows a progress /// indicator while its description is downloading. The screen title shows /// the text "Downloading..." if any of the buttons is currently downloading. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state contains a [wait] object of type [Wait]. @immutable class AppState { final Map descriptions; final Wait wait; AppState({required this.descriptions, required this.wait}); /// The copy method has a named [wait] parameter of type [Wait]. AppState copy({int? counter, Map? descriptions, Wait? wait}) => AppState( descriptions: descriptions ?? this.descriptions, wait: wait ?? this.wait, ); /// The [wait] parameter is instantiated to `Wait()`. static AppState initialState() => AppState( descriptions: {}, wait: Wait(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && descriptions == other.descriptions && wait == other.wait; @override int get hashCode => descriptions.hashCode ^ wait.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), )); } class GetDescriptionAction extends ReduxAction { int index; GetDescriptionAction(this.index); @override Future reduce() async { // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/$index/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; await Future.delayed(const Duration(seconds: 2)); // Adds some more delay. Map newDescriptions = Map.of(state.descriptions); newDescriptions[index] = description; return state.copy(descriptions: newDescriptions); } // The wait starts here. We use the index as a wait-flag reference. @override void before() => dispatch(WaitAction.add(index)); // The wait ends here. We remove the index from the wait-flag references. @override void after() => dispatch(WaitAction.remove(index)); } class MyItem extends StatelessWidget { final int index; MyItem({ required this.index, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { // Use context.select to get the description and waiting state for this specific index var description = context.select((AppState state) => state.descriptions[index] ?? ""); /// If index is waiting, `state.wait.isWaiting(index)` returns true. var waiting = context.select((AppState state) => state.wait.isWaiting(index)); Widget contents; if (waiting) contents = _progressIndicator(); else if (description.isNotEmpty) contents = _indexDescription(description); else contents = _button(context); return Container(height: 70, child: Center(child: contents)); } MaterialButton _button(BuildContext context) => MaterialButton( color: Colors.blue, child: Text("CLICK $index", style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), onPressed: () => dispatch(GetDescriptionAction(index)), ); Text _indexDescription(String description) => Text(description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center); CircularProgressIndicator _progressIndicator() => const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(Colors.red), ); } class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { /// If there is any waiting, `state.wait.isWaitingAny` will return true. var waiting = context.select((AppState state) => state.wait.isWaitingAny); return Stack( children: [ Scaffold( appBar: AppBar( title: Text(waiting ? "Downloading..." : "Advanced WaitAction Example 1")), body: ListView.builder( itemCount: 10, itemBuilder: (context, index) => MyItem(index: index), ), ), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_wait_action_advanced_2.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example is the same as the one in `main_wait_action_advanced_1.dart`. /// However, instead of only using flags in the [WaitAction], it uses both /// flags and references. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state contains a [wait] object of type [Wait]. @immutable class AppState { final Map descriptions; final Wait wait; AppState({ required this.descriptions, required this.wait, }); /// The copy method has a named [wait] parameter of type [Wait]. AppState copy({int? counter, Map? descriptions, Wait? wait}) => AppState( descriptions: descriptions ?? this.descriptions, wait: wait ?? this.wait, ); /// The [wait] parameter is instantiated to `Wait()`. static AppState initialState() => AppState( descriptions: {}, wait: Wait(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && descriptions == other.descriptions && wait == other.wait; @override int get hashCode => descriptions.hashCode ^ wait.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePage(), )); } class GetDescriptionAction extends ReduxAction { int index; GetDescriptionAction(this.index); @override Future reduce() async { // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/$index/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; await Future.delayed(const Duration(seconds: 2)); // Adds some more delay. Map newDescriptions = Map.of(state.descriptions); newDescriptions[index] = description; return state.copy(descriptions: newDescriptions); } // The wait starts here. We use the index as a wait-flag reference. @override void before() => dispatch(WaitAction.add("button-download", ref: index)); // The wait ends here. We remove the index from the wait-flag references. @override void after() => dispatch(WaitAction.remove("button-download", ref: index)); } class MyItem extends StatelessWidget { final int index; MyItem({ required this.index, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { // Use context.select to get the description and waiting state for this specific index var description = context.select((AppState state) => state.descriptions[index] ?? ""); /// If index is waiting, `state.wait.isWaiting("button-download", ref: index)` returns true. var waiting = context.select((AppState state) => state.wait.isWaiting("button-download", ref: index)); Widget contents; if (waiting) contents = _progressIndicator(); else if (description.isNotEmpty) contents = _indexDescription(description); else contents = _button(context); return Container(height: 70, child: Center(child: contents)); } MaterialButton _button(BuildContext context) => MaterialButton( color: Colors.blue, child: Text("CLICK $index", style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), onPressed: () => dispatch(GetDescriptionAction(index)), ); Text _indexDescription(String description) => Text(description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center); CircularProgressIndicator _progressIndicator() => const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(Colors.red), ); } class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { /// If there is any waiting, `state.wait.isWaitingAny` will return true. var waiting = context.select((AppState state) => state.wait.isWaitingAny); return Stack( children: [ Scaffold( appBar: AppBar( title: Text(waiting ? "Downloading..." : "Advanced WaitAction Example 2")), body: ListView.builder( itemCount: 10, itemBuilder: (context, index) => MyItem(index: index), ), ), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/main_wait_action_simple.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example is the same as the one in `main_before_and_after.dart`. /// However, instead of declaring a `MyWaitAction`, it uses the build-in /// [WaitAction]. /// /// For this to work, the [AppState] must have a [wait] field of type [Wait], /// and this field must be in the [AppState.copy] method as a named parameter. /// /// While the async process is running, the action's `before` method will /// add the action itself as a wait-flag reference: /// /// ``` /// void before() => dispatch(WaitAction.add(this)); /// ``` /// /// The [ViewModel] will read this info from `state.wait.isWaitingAny` to /// turn on the modal barrier. /// /// When the async process finishes, the action's before method will /// remove the action from the wait-flag set: /// /// ``` /// void after() => dispatch(WaitAction.remove(this)); /// ``` /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state contains a [wait] object of type [Wait]. @immutable class AppState { final int counter; final String description; final Wait wait; AppState({ required this.counter, required this.description, required this.wait, }); /// The copy method has a named [wait] parameter of type [Wait]. AppState copy({int? counter, String? description, Wait? wait}) => AppState( counter: counter ?? this.counter, description: description ?? this.description, wait: wait ?? this.wait, ); /// The [wait] parameter is instantiated to `Wait()`. static AppState initialState() => AppState( counter: 0, description: "", wait: Wait(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && description == other.description && wait == other.wait; @override int get hashCode => counter.hashCode ^ description.hashCode ^ wait.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp(home: MyHomePage()), ); } /// Use it like this: /// `class MyAction extends ReduxAction with WithWaitState` mixin WithWaitState implements ReduxAction { // Wait starts here. Add the action itself (`this`) as a wait-flag reference. @override void before() => dispatch(WaitAction.add(this)); // Wait ends here. Remove the action from the wait-flag references. @override void after() => dispatch(WaitAction.remove(this)); } class IncrementAndGetDescriptionAction extends ReduxAction with WithWaitState { @override Future reduce() async { dispatch(IncrementAction(amount: 1)); // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; return state.copy(description: description); } } class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override AppState reduce() => state.copy(counter: state.counter + amount); } class MyHomePage extends StatelessWidget { MyHomePage({Key? key}) : super(key: key); @override Widget build(BuildContext context) { // Use context.select to get state values var counter = context.select((AppState state) => state.counter); var description = context.select((AppState state) => state.description); /// While action `IncrementAndGetDescriptionAction` is running, /// [isWaiting] will be true. var isWaiting = context.select((AppState state) => state.wait.isWaitingForType()); return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Wait Action Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)), Text(description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), ], ), ), floatingActionButton: FloatingActionButton( onPressed: () => dispatch(IncrementAndGetDescriptionAction()), child: const Icon(Icons.add), ), ), if (isWaiting) ModalBarrier(color: Colors.red.withAlpha((255.0 * 0.4).round())), ], ); } } extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } ================================================ FILE: example/lib/store_connector_examples/README.md ================================================ # StoreConnector Examples This directory contains examples of how to use the `StoreConnector` widget from the AsyncRedux package. The `StoreConnector` widget is used to connect your Flutter widgets to the Redux store, allowing them to access the state and dispatch actions. It's generally not necessary to use the `StoreConnector` widget directly, as `AsyncRedux` allows you to use the extensions `context.state`, `context.select()`, `context.read()`, `context.dispatch()`, etc. However, the `StoreConnector` allows you to completely separate the presentation layer from the business logic, including the selection of the part of the state that the widget needs. This can make your code more modular and easier to maintain. When should you use the `StoreConnector`? * When you want to create a reusable widget that is not coupled to AsyncRedux and the Redux store. * When you want to test the presentation layer of your app in isolation, without needing to set up the Redux store. * When the selection of the state is complex, and you want to encapsulate it in a separate class (ViewModel). A good rule of thumb is to start with using `context.state`, `context.select()`, `context.dispatch()`, etc. and only switch to using the `StoreConnector` when you find a specific need for it. ## Code examples: The code below uses "context extensions" directly in the widget (no smart/dumb widget separation): ```dart // Dumb widget (Uses Context extensions) class MyHomePageContent extends StatelessWidget { ... Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Counter: ${context.select((AppState state) => state.counter)}'), if (context.isWaiting([IncrementAction, MultiplyAction])) CircularProgressIndicator(), Row( children: [ ElevatedButton( onPressed: () => context.dispatch(IncrementAction()), child: Text('Increment') ), ElevatedButton( onPressed: () => context.dispatch(MultiplyAction()), child: Text('Multiply') ), ], ), ], ); ``` The code below is equivalent. It uses "context extensions" and also smart/dumb widget separation: ```dart // Smart widget (Uses Context extensions) return MyHomePageContent( title: 'IsWaiting multiple actions', counter: context.select((state) => state.counter), isCalculating: context.isWaiting([IncrementAction, MultiplyAction]), increment: () => context.dispatch(IncrementAction()), multiply: () => context.dispatch(MultiplyAction()), ); // Dumb widget (no direct Redux usage) class MyHomePageContent extends StatelessWidget { ... Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Counter: $counter'), if (isCalculating) CircularProgressIndicator(), Row( children: [ ElevatedButton(onPressed: increment, child: Text('Increment')), ElevatedButton(onPressed: multiply, child: Text('Multiply')), ... ``` The code below is equivalent. It uses StoreConnector, Factory, View Model, and also smart/dumb widget separation: ``` // Smart widget (Uses StoreConnector/Factory/Vm) Widget build(BuildContext context) { return StoreConnector( vm: () => CounterVmFactory(), // Here, uses the factory defined below. shouldUpdateModel: (s) => s.counter >= 0, builder: (context, vm) { return MyHomePageContent( title: 'IsWaiting multiple actions (Store Connector)', counter: vm.counter, isCalculating: vm.isCalculating, increment: vm.increment, multiply: vm.multiply, ); }, ); } class CounterVmFactory extends VmFactory { CounterVm fromStore() => CounterVm( // Here, uses the view model defined below. counter: state.counter, isCalculating: isWaiting([IncrementAction, MultiplyAction]), increment: () => dispatch(IncrementAction()), multiply: () => dispatch(MultiplyAction()), ); } class CounterVm extends Vm { final int counter; final bool isCalculating; final VoidCallback increment, multiply; CounterVm({ required this.counter, required this.isCalculating, required this.increment, required this.multiply, }) : super(equals: [counter, isCalculating]); } // Dumb widget (no direct Redux usage) class MyHomePageContent extends StatelessWidget { ... Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Counter: $counter'), if (isCalculating) CircularProgressIndicator(), Row( children: [ ElevatedButton(onPressed: increment, child: Text('Increment')), ElevatedButton(onPressed: multiply, child: Text('Multiply')), ... ``` ## The difference between the 3 approaches ### Approach 1: Context Extensions (No Separation) Uses context extensions directly in the widget without any smart/dumb widget separation. All logic and presentation are in one place. ### Approach 2: Context Extensions (With Smart/Dumb Separation) Uses context extensions in a "smart" container widget that passes data and callbacks to a "dumb" presentational widget. ### Approach 3: StoreConnector with VmFactory Uses StoreConnector, VmFactory, and ViewModels with smart/dumb widget separation for maximum decoupling. ## Key Differences: ### 1. Boilerplate & Complexity - **Approach 1 (Direct Context Extensions)**: Minimal boilerplate, everything inline. Simplest to write and understand initially. - **Approach 2 (Context Extensions + Separation)**: Moderate boilerplate, requires defining props for the dumb widget. - **Approach 3 (StoreConnector)**: Most boilerplate, requires 3 additional classes (`CounterVm`, `CounterVmFactory`, and using `StoreConnector`). ### 2. Where Business Logic Lives - **Approach 1**: Business logic (selectors, dispatches) mixed directly with UI code in the build method. - **Approach 2**: Business logic in the smart widget, but still uses context extensions directly. - **Approach 3**: Business logic fully encapsulated in `VmFactory.fromStore()` with no direct Redux dependencies in widgets. ### 3. Separation of Concerns - **Approach 1**: No separation - Redux awareness and UI are completely intertwined. - **Approach 2**: Partial separation - UI is isolated in dumb widget, but smart widget still directly uses Redux. - **Approach 3**: Full separation - Complete decoupling through ViewModel abstraction. ### 4. Reusability - **Approach 1**: Widget is tightly coupled to Redux store structure. Hard to reuse or test without full Redux setup. - **Approach 2**: Dumb widget is reusable with any data source. Smart widget still tied to Redux. - **Approach 3**: Both dumb widget and ViewModel pattern are highly reusable. VmFactory can be shared across multiple widgets. ### 5. Testing Strategy - **Approach 1**: Requires full Redux store setup to test. Cannot test UI in isolation. - **Approach 2**: Can test dumb widget with simple props. Smart widget still needs Redux for testing. - **Approach 3**: Can test dumb widget with props, and separately unit test VmFactory business logic without UI. ### 6. Refactoring & Maintenance - **Approach 1**: Changes to store structure require updates throughout the widget. Hard to track all dependencies. - **Approach 2**: Store changes only affect smart widget. Dumb widget remains stable. - **Approach 3**: Store changes isolated to VmFactory. Both widgets and ViewModel interface can remain stable. ## Recommendations: ### Use Approach 1 (Direct Context Extensions) when: - You're building simple widgets or prototypes - The widget is used in only one place - Testing the full widget with Redux is acceptable - You want minimal boilerplate and fastest development ### Use Approach 2 (Context Extensions + Smart/Dumb) when: - You want better testability without full ViewModel complexity - The UI component might be reused with different data - You prefer a balance between simplicity and separation - Your team is familiar with Redux but wants cleaner components ### Use Approach 3 (StoreConnector/VmFactory) when: - You have complex business logic requiring isolated testing - Multiple widgets need the same state transformations - You want complete decoupling and maximum testability - You're building large, team-based applications - You need to enforce consistent architectural patterns ### General Guidelines: The "dumb widget" pattern (used in Approaches 2 & 3) is valuable because: 1. It makes widgets easily testable without store 2. It makes the UI reusable with different data sources 3. It clearly shows the widget's API (what data it needs) Start with Approach 1 for simplicity, then refactor to Approach 2 or 3 as your needs grow. The transition path is natural: - **1 → 2**: Extract props to create a dumb widget - **2 → 3**: Replace context extensions with StoreConnector and VmFactory ================================================ FILE: example/lib/store_connector_examples/main_async__store_connector.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example demonstrates: /// - The use of [StoreConnector], [VmFactory], and [ViewModel]. /// - Doing async work inside an action. /// /// It shows a counter, a text description, and a button. /// When the button is tapped, the counter will increment synchronously, /// while an async process downloads some text description that relates /// to the counter number (using the Star Wars API: https://swapi.dev). /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter and a description. @immutable class AppState { final int counter; final String description; AppState({ required this.counter, required this.description, }); AppState copy({int? counter, String? description}) => AppState( counter: counter ?? this.counter, description: description ?? this.description, ); static AppState initialState() => AppState(counter: 0, description: ""); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && description == other.description; @override int get hashCode => counter.hashCode ^ description.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } /// This action increments the counter by 1, /// and then gets some description text relating to the new counter number. class IncrementAndGetDescriptionAction extends ReduxAction { // // Async reducer. // To make it async we simply return Future instead of AppState. @override Future reduce() async { // First, we increment the counter, synchronously. dispatch(IncrementAction(amount: 1)); // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; // After we get the response, we can modify the state with it, // without having to dispatch another action. return state.copy(description: description); } } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); // Synchronous reducer. @override AppState reduce() => state.copy(counter: state.counter + amount); } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, description: vm.description, onIncrement: vm.onIncrement, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() => ViewModel( counter: state.counter, description: state.description, onIncrement: _onIncrement, ); void _onIncrement() { dispatch(IncrementAndGetDescriptionAction()); print('Counter in the the view-model = ${vm.counter}'); print( 'Counter in the state when the view-model was created = ${state.counter}'); print('Counter in the current state = ${currentState().counter}'); } } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel extends Vm { final int counter; final String description; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.description, required this.onIncrement, }) : super(equals: [counter, description]); } class MyHomePage extends StatelessWidget { final int? counter; final String? description; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.description, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Increment Example (StoreConnector)')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)), Text(description!, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_async_base_factory__store_connector.dart.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example demonstrates: /// - The use of [StoreConnector], [VmFactory], and [ViewModel]. /// - Doing async work inside an action. /// - How to create a [BaseFactory] to reduce code duplication. Once you add /// this class to your code, it knows your state is [AppState], and you can /// avoid repeating that in all your factories. For example, instead of writing /// `VmFactory`, you can simply write /// `BaseVmFactory`. /// /// It shows a counter, a text description, and a button. /// When the button is tapped, the counter will increment synchronously, /// while an async process downloads some text description that relates /// to the counter number (using the Star Wars API: https://swapi.dev). /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter and a description. @immutable class AppState { final int counter; final String description; AppState({ required this.counter, required this.description, }); AppState copy({int? counter, String? description}) => AppState( counter: counter ?? this.counter, description: description ?? this.description, ); static AppState initialState() => AppState(counter: 0, description: ""); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && description == other.description; @override int get hashCode => counter.hashCode ^ description.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } /// This action increments the counter by 1, /// and then gets some description text relating to the new counter number. class IncrementAndGetDescriptionAction extends ReduxAction { // // Async reducer. // To make it async we simply return Future instead of AppState. @override Future reduce() async { // First, we increment the counter, synchronously. dispatch(IncrementAction(amount: 1)); // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; // After we get the response, we can modify the state with it, // without having to dispatch another action. return state.copy(description: description); } } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); // Synchronous reducer. @override AppState reduce() => state.copy(counter: state.counter + amount); } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, description: vm.description, onIncrement: vm.onIncrement, ), ); } } abstract class BaseFactory extends VmFactory { BaseFactory([T? connector]) : super(connector); } /// Factory that creates a view-model for the StoreConnector. class Factory extends BaseFactory { Factory(connector) : super(connector); @override ViewModel fromStore() => ViewModel( counter: state.counter, description: state.description, onIncrement: _onIncrement, ); void _onIncrement() { dispatch(IncrementAndGetDescriptionAction()); print('Counter in the the view-model = ${vm.counter}'); print( 'Counter in the state when the view-model was created = ${state.counter}'); print('Counter in the current state = ${currentState().counter}'); } } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel extends Vm { final int counter; final String description; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.description, required this.onIncrement, }) : super(equals: [counter, description]); } class MyHomePage extends StatelessWidget { final int? counter; final String? description; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.description, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Increment Example (StoreConnector)')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)), Text(description!, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_environment__store_connector.dart ================================================ import 'dart:math'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows how to provide an environment to the Store, to help /// with dependency injection. The environment is a container for the /// injected services. You can have many environment implementations, one /// for production, others for tests etc. In this case, we're using the /// [DependenciesImpl]. /// /// You should extend [ReduxAction] to provide typed access to the [Dependencies] /// inside your actions. /// /// In case you use [StoreConnector], you should also extend [VmFactory] to /// provide typed access to the [Dependencies] inside your factories. /// void main() { store = Store( initialState: 0, dependencies: (store) => DependenciesImpl(), ); runApp(MyApp()); } /// The environment is a container for the injected services. abstract class Dependencies { int incrementer(int value, int amount); int limit(int value); } /// We can have many environment implementations, one for production, others for /// staging, tests etc. In this case, we're using the [DependenciesImpl]. class DependenciesImpl implements Dependencies { @override int incrementer(int value, int amount) => value + amount; /// We'll limit the counter at 5. @override int limit(int value) => min(value, 5); } /// Extend [ReduxAction] to provide typed access to the [Dependencies]. abstract class Action extends ReduxAction { Dependencies get dependencies => super.store.dependencies as Dependencies; } /// Extend [VmFactory] to provide typed access to the [Dependencies] when /// using [StoreConnector]. abstract class AppFactory extends VmFactory { AppFactory([T? connector]) : super(connector); Dependencies get dependencies => store.dependencies as Dependencies; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } /// This action increments the counter by [amount], using [env]. class IncrementAction extends Action { final int amount; IncrementAction({required this.amount}); @override int reduce() => dependencies.incrementer(state, amount); } /// This widget is a connector. It uses a [StoreConnector] to connect the store /// to [MyHomePage] (the dumb-widget). Each time the state changes, it creates /// a view-model, and compares it with the view-model created with the previous /// state. If the view-model changed, the connector rebuilds. If you don't need /// to use connectors, you can just use `context.state`, `context.select`, /// `context.dispatch` etc, directly in your widgets. class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, onIncrement: vm.onIncrement, ), ); } } /// Factory that creates a view-model ([ViewModel]) for the [StoreConnector]. /// It uses [env]. class Factory extends AppFactory { Factory(connector) : super(connector); @override ViewModel fromStore() => ViewModel( counter: dependencies.limit(state), onIncrement: () => dispatch(IncrementAction(amount: 1)), ); } /// A view-model is a helper object to a [StoreConnector] widget. It holds the /// part of the Store state the corresponding dumb-widget needs. class ViewModel extends Vm { final int counter; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.onIncrement, }) : super(equals: [counter]); } class MyHomePage extends StatelessWidget { final int? counter; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Dependency Injection Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'You have pushed the button this many times:\n' '(limited to 5)', textAlign: TextAlign.center, ), Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_event__store_connector.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a text-field, and two buttons. /// When the first button is tapped, an async process downloads /// some text from the internet and puts it in the text-field. /// When the second button is tapped, the text-field is cleared. /// /// This is meant to demonstrate the use of "events" to change /// a controller state. /// /// It also demonstrates the use of an abstract class [BarrierAction] /// to override the action's before() and after() methods. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state, which in this case is a counter and two events. @immutable class AppState { final int counter; final bool waiting; final Event clearTextEvt; final Event changeTextEvt; AppState({ required this.counter, required this.waiting, required this.clearTextEvt, required this.changeTextEvt, }); AppState copy({ int? counter, bool? waiting, Event? clearTextEvt, Event? changeTextEvt, }) => AppState( counter: counter ?? this.counter, waiting: waiting ?? this.waiting, clearTextEvt: clearTextEvt ?? this.clearTextEvt, changeTextEvt: changeTextEvt ?? this.changeTextEvt, ); static AppState initialState() => AppState( counter: 1, waiting: false, clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && waiting == other.waiting; @override int get hashCode => counter.hashCode ^ waiting.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), ), ); } /// This action orders the text-controller to clear. class ClearTextAction extends ReduxAction { @override AppState reduce() => state.copy(clearTextEvt: Event()); } /// Actions that extend [BarrierAction] show a modal barrier while their async processes run. abstract class BarrierAction extends ReduxAction { @override void before() => dispatch(_WaitAction(true)); @override void after() => dispatch(_WaitAction(false)); } class _WaitAction extends ReduxAction { final bool waiting; _WaitAction(this.waiting); @override AppState reduce() => state.copy(waiting: waiting); } /// This action downloads some new text, and then creates an event /// that tells the text-controller to display that new text. class ChangeTextAction extends BarrierAction { @override Future reduce() async { // // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String newText = json['name'] ?? 'Unknown character'; return state.copy( counter: state.counter + 1, changeTextEvt: Event(newText), ); } } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel vm) => MyHomePage( waiting: vm.waiting, clearTextEvt: vm.clearTextEvt, changeTextEvt: vm.changeTextEvt, onClear: vm.onClear, onChange: vm.onChange, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() => ViewModel( waiting: state.waiting, clearTextEvt: state.clearTextEvt, changeTextEvt: state.changeTextEvt, onClear: () => dispatch(ClearTextAction()), onChange: () => dispatch(ChangeTextAction()), ); } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel extends Vm { final bool? waiting; final Event? clearTextEvt; final Event? changeTextEvt; final VoidCallback onClear; final VoidCallback onChange; ViewModel({ required this.waiting, required this.clearTextEvt, required this.changeTextEvt, required this.onClear, required this.onChange, }) : super(equals: [waiting!, clearTextEvt!, changeTextEvt!]); } class MyHomePage extends StatefulWidget { final bool? waiting; final Event? clearTextEvt; final Event? changeTextEvt; final VoidCallback? onClear; final VoidCallback? onChange; MyHomePage({ Key? key, this.waiting, this.clearTextEvt, this.changeTextEvt, this.onClear, this.onChange, }) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { late TextEditingController controller; @override void initState() { super.initState(); controller = TextEditingController(); } @override void didUpdateWidget(MyHomePage oldWidget) { super.didUpdateWidget(oldWidget); consumeEvents(); } void consumeEvents() { if (widget.clearTextEvt!.consume()) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) controller.clear(); }); String? newText = widget.changeTextEvt!.consume(); if (newText != null) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) controller.text = newText; }); } @override Widget build(BuildContext context) { return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Event Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('This is a TextField. Click to edit it:'), TextField(controller: controller), const SizedBox(height: 20), FloatingActionButton(onPressed: widget.onChange, child: const Text("Change")), const SizedBox(height: 20), FloatingActionButton(onPressed: widget.onClear, child: const Text("Clear")), ], ), ), ), if (widget.waiting!) ModalBarrier(color: Colors.red.withAlpha((255.0 * 0.4).round())), ], ); } } ================================================ FILE: example/lib/store_connector_examples/main_extension_vs_store_connector.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; /// This example shows a counter and a button. /// When the button is tapped, the counter will increment synchronously. void main() { store = Store(initialState: AppState(counter: 0, something: 0)); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: const MaterialApp(home: HomePage()), ); } class HomePage extends StatelessWidget { const HomePage({ super.key, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Connector vs Provider Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ GetsStateFromStoreConnector(), const SizedBox(height: 40), GetsStateFromBuildContextExtension(), ], ), ), floatingActionButton: FloatingActionButton( disabledElevation: 0, onPressed: () => context.dispatch(IncrementAction()), child: const Icon(Icons.add), ), ); } } class IncrementAction extends ReduxAction { @override AppState reduce() { return AppState( counter: state.counter + 1, something: state.something, ); } } class GetsStateFromStoreConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( converter: (Store store) => store.state.counter, builder: (context, value) => Column( children: [ Text('$value', style: const TextStyle(fontSize: 30, color: Colors.black)), const Text( 'Value read with the StoreConnector:\n`StoreConnector(builder: (context, value) => ...)`', style: const TextStyle(fontSize: 13), textAlign: TextAlign.center, ), ], ), ); } } class GetsStateFromBuildContextExtension extends StatelessWidget { @override Widget build(BuildContext context) { return Column( children: [ Text('${context.state.counter}', style: const TextStyle(fontSize: 30, color: Colors.black)), const Text( 'Value read with the StoreProvider:\n`context.state.counter`', style: TextStyle(fontSize: 13), textAlign: TextAlign.center, ), ], ); } } extension _BuildContextExtension on BuildContext { AppState get state => getState(); } class AppState { int counter; int something; AppState({ required this.counter, required this.something, }); @override String toString() => 'AppState{counter: $counter}'; @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter; @override int get hashCode => counter.hashCode; } ================================================ FILE: example/lib/store_connector_examples/main_infinite_scroll__store_connector.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a List of Star Wars characters. /// Scrolling to the bottom of the list will async load the next 20 characters. /// Scrolling past the top of the list (pull to refresh) will use /// `dispatchAndWait` to dispatch an action and get a future that tells the /// `RefreshIndicator` when the action completes. /// /// `isWaiting(LoadMoreAction)` prevents the user from loading more while the /// async action is running. /// void main() { var state = AppState.initialState(); store = Store( initialState: state, actionObservers: [Log.printer()], modelObserver: DefaultModelObserver(), ); runApp(MyApp()); } @immutable class AppState { final List numTrivia; AppState({required this.numTrivia}); AppState copy({List? numTrivia}) => AppState(numTrivia: numTrivia ?? this.numTrivia); static AppState initialState() => AppState(numTrivia: []); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && numTrivia == other.numTrivia; @override int get hashCode => numTrivia.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( debugShowCheckedModeBanner: false, home: MyHomePageConnector(), ), ); } class LoadMoreAction extends ReduxAction { @override Future reduce() async { List list = List.from(state.numTrivia); int start = state.numTrivia.length + 1; // Fetch 20 people concurrently. final responses = await Future.wait( List.generate(20, (i) => get(Uri.parse('https://swapi.dev/api/people/${start + i}/'))), ); for (final response in responses) { if (response.statusCode == 200) { final data = jsonDecode(response.body); list.add(data['name'] ?? 'Unknown character'); } } return state.copy(numTrivia: list); } } class RefreshAction extends ReduxAction { @override Future reduce() async { List list = []; // Fetch the first 20 people concurrently. final responses = await Future.wait( List.generate( 20, (i) => get(Uri.parse('https://swapi.dev/api/people/${i + 1}/'))), ); for (final response in responses) { if (response.statusCode == 200) { final data = jsonDecode(response.body); list.add(data['name'] ?? 'Unknown character'); } } return state.copy(numTrivia: list); } } class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( debug: this, vm: () => Factory(this), onInit: (st) => st.dispatch(RefreshAction()), builder: (BuildContext context, ViewModel vm) => MyHomePage( numTrivia: vm.numTrivia, isLoading: vm.isLoading, loadMore: vm.loadMore, onRefresh: vm.onRefresh, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() { return ViewModel( numTrivia: state.numTrivia, isLoading: isWaiting(LoadMoreAction), loadMore: () => dispatch(LoadMoreAction()), onRefresh: () => dispatchAndWait(RefreshAction()), ); } } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel extends Vm { final List numTrivia; final bool isLoading; final VoidCallback loadMore; final Future Function() onRefresh; ViewModel({ required this.numTrivia, required this.isLoading, required this.loadMore, required this.onRefresh, }) : super(equals: [ numTrivia, isLoading, ]); } class MyHomePage extends StatefulWidget { final List numTrivia; final bool isLoading; final VoidCallback loadMore; final Future Function() onRefresh; MyHomePage({ Key? key, required this.numTrivia, required this.isLoading, required this.loadMore, required this.onRefresh, }) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State { late ScrollController _controller; @override void initState() { _controller = ScrollController() ..addListener(() { if (!widget.isLoading && _controller.position.maxScrollExtent == _controller.position.pixels) { widget.loadMore(); } }); super.initState(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Infinite Scroll Example (StoreConnector)')), body: (widget.numTrivia.isEmpty) ? Container() : RefreshIndicator( onRefresh: widget.onRefresh, child: ListView.builder( controller: _controller, itemCount: widget.numTrivia.length + (widget.isLoading ? 1 : 0), itemBuilder: (context, index) { // Show loading spinner at the end if (index == widget.numTrivia.length) { return const Padding( padding: EdgeInsets.all(16.0), child: Center(child: CircularProgressIndicator()), ); } else return ListTile( leading: CircleAvatar(child: Text(index.toString())), title: Text(widget.numTrivia[index]), ); }, ), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_is_waiting_works_when_multiple_actions__store_connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; /// This example shows how to show a spinner while any of two actions /// ([IncrementAction] and [MultiplyAction]) is running. /// /// Writing this: /// /// ```dart /// isWaiting([IncrementAction, MultiplyAction]) /// ``` /// /// Is the same as writing this: /// /// ```dart /// isWaiting(IncrementAction) || context.isWaiting(MultiplyAction) /// ``` /// /// See how the `isCalculating` variable is defined in the [CounterVmFactory]. /// void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { var store = Store(initialState: AppState(counter: 0)); store.onChange.listen(print); return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: StoreProvider( store: store, child: const MyHomePage(), ), ); } } class MyHomePage extends StatelessWidget { const MyHomePage({super.key}); /// The code below, which uses a [StoreConnector], [CounterVmFactory], /// and [CounterVm], is equivalent to: /// /// ```dart /// return MyHomePageContent( /// title: 'IsWaiting multiple actions', /// counter: context.select((state) => state.counter), /// isCalculating: context.isWaiting([IncrementAction, MultiplyAction]), /// increment: () => context.dispatch(IncrementAction()), /// multiply: () => context.dispatch(MultiplyAction()), /// ); /// ``` @override Widget build(BuildContext context) { return StoreConnector( vm: () => CounterVmFactory(), shouldUpdateModel: (s) => s.counter >= 0, builder: (context, vm) { return MyHomePageContent( title: 'IsWaiting multiple actions (Store Connector)', counter: vm.counter, isCalculating: vm.isCalculating, increment: vm.increment, multiply: vm.multiply, ); }, ); } } class MyHomePageContent extends StatelessWidget { const MyHomePageContent({ super.key, required this.title, required this.counter, required this.isCalculating, required this.increment, required this.multiply, }); final String title; final int counter; final bool isCalculating; final VoidCallback increment, multiply; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Result:'), Text( '$counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: Column( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton( onPressed: isCalculating ? null : increment, elevation: isCalculating ? 0 : 6, backgroundColor: isCalculating ? Colors.grey[300] : Colors.blue, child: isCalculating ? const Padding( padding: const EdgeInsets.all(16.0), child: const CircularProgressIndicator(), ) : const Icon(Icons.add), ), const SizedBox(height: 16), FloatingActionButton( onPressed: isCalculating ? null : multiply, elevation: isCalculating ? 0 : 6, backgroundColor: isCalculating ? Colors.grey[300] : Colors.blue, child: isCalculating ? const Padding( padding: const EdgeInsets.all(16.0), child: const CircularProgressIndicator(), ) : const Icon(Icons.close), ) ], ), ); } } class AppState { final int counter; AppState({required this.counter}); AppState copy({int? counter}) => AppState(counter: counter ?? this.counter); @override String toString() { return '.\n.\n.\nAppState{counter: $counter}\n.\n.\n'; } } class CounterVm extends Vm { final int counter; final bool isCalculating; final VoidCallback increment, multiply; CounterVm({ required this.counter, required this.isCalculating, required this.increment, required this.multiply, }) : super(equals: [counter, isCalculating]); } class CounterVmFactory extends VmFactory { @override CounterVm fromStore() => CounterVm( counter: state.counter, isCalculating: isWaiting([IncrementAction, MultiplyAction]), increment: () => dispatch(IncrementAction()), multiply: () => dispatch(MultiplyAction()), ); } class IncrementAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 1)); return AppState(counter: state.counter + 1); } } class MultiplyAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 1)); return AppState(counter: state.counter * 2); } } ================================================ FILE: example/lib/store_connector_examples/main_is_waiting_works_when_state_unchanged__store_connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// This example demonstrates that `isWaiting` works even for actions that /// return `null` (i.e., actions that don't change the state). void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { var store = Store(initialState: AppState(counter: 0)); store.onChange.listen(print); return MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: StoreProvider( store: store, child: const MyHomePage(), ), ); } } class MyHomePage extends StatelessWidget { const MyHomePage({super.key}); @override Widget build(BuildContext context) { return StoreConnector( vm: () => CounterVmFactory(), shouldUpdateModel: (s) => s.counter >= 0, builder: (context, vm) { return MyHomePageContent( title: 'IsWaiting works when state unchanged', counter: vm.counter, isIncrementing: vm.isIncrementing, increment: vm.increment, ); }, ); } } class MyHomePageContent extends StatelessWidget { const MyHomePageContent({ super.key, required this.title, required this.counter, required this.isIncrementing, required this.increment, }); final String title; final int counter; final bool isIncrementing; final VoidCallback increment; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You pushed the button:'), Text( '$counter', style: Theme.of(context).textTheme.headlineMedium, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: isIncrementing ? null : increment, elevation: isIncrementing ? 0 : 6, backgroundColor: isIncrementing ? Colors.grey[300] : Colors.blue, child: isIncrementing ? const Padding( padding: const EdgeInsets.all(16.0), child: const CircularProgressIndicator(), ) : const Icon(Icons.add), ), ); } } class AppState { final int counter; AppState({required this.counter}); AppState copy({int? counter}) => AppState(counter: counter ?? this.counter); @override String toString() { return '.\n.\n.\nAppState{counter: $counter}\n.\n.\n'; } } class CounterVm extends Vm { final int counter; final bool isIncrementing; final VoidCallback increment; CounterVm({ required this.counter, required this.isIncrementing, required this.increment, }) : super(equals: [ counter, isIncrementing, ]); } class CounterVmFactory extends VmFactory { @override CounterVm fromStore() => CounterVm( counter: state.counter, isIncrementing: isWaiting(IncrementAction), increment: () => dispatch(IncrementAction()), ); } class IncrementAction extends ReduxAction { @override Future reduce() async { dispatch(DoIncrementAction()); await Future.delayed(const Duration(milliseconds: 1250)); return null; } } class DoIncrementAction extends ReduxAction { @override AppState? reduce() { return AppState(counter: state.counter + 1); } } ================================================ FILE: example/lib/store_connector_examples/main_navigate__store_connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; final navigatorKey = GlobalKey(); void main() async { NavigateAction.setNavigatorKey(navigatorKey); store = Store(initialState: AppState()); runApp(MyApp()); } final routes = { '/': (BuildContext context) => Page1Connector(), "/myRoute": (BuildContext context) => Page2Connector(), }; class AppState {} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( routes: routes, navigatorKey: navigatorKey, ), ); } } class Page extends StatelessWidget { final Color? color; final String? text; final VoidCallback onChangePage; Page({this.color, this.text, required this.onChangePage}); @override Widget build(BuildContext context) => ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: color), child: Text(text!), onPressed: onChangePage, ); } class Page1Connector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory1(), builder: (BuildContext context, ViewModel1 vm) => Page( color: Colors.red, text: "Tap me to push a new route!", onChangePage: vm.onChangePage, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory1 extends VmFactory { @override ViewModel1 fromStore() => ViewModel1(onChangePage: () => dispatch(NavigateAction.pushNamed("/myRoute"))); } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel1 extends Vm { final VoidCallback onChangePage; ViewModel1({required this.onChangePage}); } class Page2Connector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory2(), builder: (BuildContext context, ViewModel2 vm) => Page( color: Colors.blue, text: "Tap me to pop this route!", onChangePage: vm.onChangePage, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory2 extends VmFactory { @override ViewModel2 fromStore() => ViewModel2( onChangePage: () => dispatch(NavigateAction.pop()), ); } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel2 extends Vm { final VoidCallback onChangePage; ViewModel2({required this.onChangePage}); } ================================================ FILE: example/lib/store_connector_examples/main_null_viewmodel__connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows a counter and a button. It's similar to the `main.dart` /// example. However when the counter is `5` the view-model created by the /// Factory's `fromStore()` will be `null`. /// /// The `StoreConnector` accept `null` view-models. And when it gets a `null` /// view-model it simply replaces the screen with a `ViewModel is null` text. /// void main() { store = Store(initialState: 0); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override int reduce() => state + amount; } class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { /// /// 1) The StoreConnector uses `ViewModel?` instead of `ViewModel`. return StoreConnector( vm: () => Factory(this), /// 2) The builder uses `ViewModel?` instead of `ViewModel`. builder: (BuildContext context, ViewModel? vm) { return (vm == null) ? const Material( child: Center( child: const Text("ViewModel is null"), ), ) : MyHomePage( counter: vm.counter, onIncrement: vm.onIncrement, ); }, ); } } class Factory extends VmFactory { Factory(connector) : super(connector); /// 3) The `fromStore` method uses `ViewModel?` instead of `ViewModel`. @override ViewModel? fromStore() { return (store.state == 5) ? null : ViewModel( counter: state, onIncrement: () => dispatch(IncrementAction(amount: 1)), ); } } class ViewModel extends Vm { final int counter; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.onIncrement, }) : super(equals: [counter]); } class MyHomePage extends StatelessWidget { final int? counter; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Null ViewModel Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_should_update_model__store_connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows how to prevent creating view-models from invalid states, /// using [StoreConnector.shouldUpdateModel]. /// /// When the button is tapped, the counter will increment 5 times, /// synchronously. So, the sequence would be 0, 5, 10, 15, 20, 25 etc. /// /// However, we consider odd numbers invalid (StoreConnector.shouldUpdateModel /// returns `false` for odd numbers). /// /// Therefore, it will display 0, 4, 10, 14, 20, 24 etc. /// void main() { store = Store(initialState: 0); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override int reduce() => state + amount; } class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), // // Should update the view-model only when the counter is even. shouldUpdateModel: (int count) => count % 2 == 0, // builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, onIncrement: vm.onIncrement, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() { return ViewModel( counter: state, onIncrement: () { // Increment 5 times. dispatch(IncrementAction(amount: 1)); dispatch(IncrementAction(amount: 1)); dispatch(IncrementAction(amount: 1)); dispatch(IncrementAction(amount: 1)); dispatch(IncrementAction(amount: 1)); }, ); } } class ViewModel extends Vm { final int counter; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.onIncrement, }) : super(equals: [counter]); } class MyHomePage extends StatelessWidget { final int? counter; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Increment Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Padding( padding: const EdgeInsets.all(20.0), child: Text('Each time you push the button it increments 5 times.\n\n' 'But only even values are valid to appear in the UI.\n\n' 'This demonstrates the use of StoreConnector.shouldUpdateModel.'), ), Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_spinner__store_connector.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; late Store store; /// This example shows a counter and a button. /// When the button is tapped, the counter will increment asynchronously. void main() { store = Store(initialState: AppState(counter: 0, something: 0)); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: UserExceptionDialog( child: const HomePage(), ), ), ); } class HomePage extends StatelessWidget { const HomePage({ super.key, }); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Spinner With StoreConnector')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), CounterWidget(), ], ), ), // Here we disable the button while the `WaitAndIncrementAction` action is running. floatingActionButton: Row( mainAxisSize: MainAxisSize.min, children: [ _FailWithDialog_ButtonConnector(), const SizedBox(width: 12), _FailNoDialog_ButtonConnector(), const SizedBox(width: 12), _PlusButtonConnector(), ], ), ); } } class _PlusButtonConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (context, vm) { return vm.isWaiting1 ? const FloatingActionButton( disabledElevation: 0, onPressed: null, child: SizedBox(width: 25, height: 25, child: CircularProgressIndicator())) : FloatingActionButton( disabledElevation: 0, onPressed: () => context.dispatch(WaitAndIncrementAction()), child: const Icon(Icons.add), ); }, ); } } class _FailWithDialog_ButtonConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (context, vm) { return vm.isWaiting2 ? const FloatingActionButton( disabledElevation: 0, onPressed: null, child: SizedBox(width: 25, height: 25, child: CircularProgressIndicator())) : FloatingActionButton( disabledElevation: 0, onPressed: () => context.dispatch(FailWithDialogAction()), child: const Text('Fail with dialog', textAlign: TextAlign.center), ); }, ); } } class _FailNoDialog_ButtonConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (context, vm) { return vm.isWaiting3 ? const FloatingActionButton( disabledElevation: 0, onPressed: null, child: SizedBox(width: 25, height: 25, child: CircularProgressIndicator())) : FloatingActionButton( disabledElevation: 0, onPressed: () => context.dispatch(FailNoDialogAction()), child: const Text('Fail no dialog', textAlign: TextAlign.center), ); }, ); } } class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() { return ViewModel( isWaiting1: isWaiting(WaitAndIncrementAction), isWaiting2: isWaiting(FailWithDialogAction), isWaiting3: isWaiting(FailNoDialogAction), ); } } class ViewModel extends Vm { final bool isWaiting1, isWaiting2, isWaiting3; ViewModel({ required this.isWaiting1, required this.isWaiting2, required this.isWaiting3, }) : super(equals: [isWaiting1, isWaiting2, isWaiting3]); } /// This action waits for 2 seconds, then increments the counter by 1. class WaitAndIncrementAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 2)); return AppState( counter: state.counter + 1, something: state.something, ); } } /// This action waits for 2 seconds, then fails with a dialog. class FailWithDialogAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 2)); throw const UserException('The increment failed!'); } } /// This action waits for 2 seconds, then fails with no dialog. class FailNoDialogAction extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(seconds: 2)); throw const UserException('The increment failed!').noDialog; } } class CounterWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Text( '${context.state.counter}', style: const TextStyle(fontSize: 40, color: Colors.black), ); } } extension _BuildContextExtension on BuildContext { AppState get state => getState(); } class AppState { int counter; int something; AppState({ required this.counter, required this.something, }); @override String toString() => 'AppState{counter: $counter}'; @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter; @override int get hashCode => counter.hashCode; } ================================================ FILE: example/lib/store_connector_examples/main_static_view_model__store_connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows how to use the same view-model architecture of the /// flutter_redux package. This is specially useful if you are migrating /// from flutter_redux. /// /// Here, you use the `StoreConnector`'s `converter` parameter, /// instead of the `vm` parameter. /// /// Your `ViewModel` class may or may not extend `Vm`, but it /// must have a static factory method, usually named `fromStore`: /// /// `converter: (store) => ViewModel.fromStore(store)`. /// void main() { store = Store(initialState: 0); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override int reduce() => state + amount; } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( converter: (store) => ViewModel.fromStore(store), builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, onIncrement: vm.onIncrement, ), ); } } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel extends Vm { final int counter; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.onIncrement, }) : super(equals: [counter]); /// Static factory called by the StoreConnector's converter parameter. static ViewModel fromStore(Store store) { return ViewModel( counter: store.state, onIncrement: () => store.dispatch(IncrementAction(amount: 1)), ); } } class MyHomePage extends StatelessWidget { final int? counter; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Static Factory ViewModel Example'), backgroundColor: Colors.green, ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), backgroundColor: Colors.green, ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_sync__store_connector.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example demonstrates: /// - The use of [StoreConnector], [VmFactory], and [ViewModel]. /// - Doing synchronous work inside an action. /// /// It shows a counter and a button. /// When the button is tapped, the counter will increment synchronously. /// /// Note: In this simple example, the app state is simply a number (the /// counter), so the store is defined as `Store` with initial state 0. /// For more realistic examples of app states, see the other examples in this /// package, that define state as an immutable class named `AppState`. /// void main() { store = Store(initialState: 0); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } /// This action increments the counter by [amount]]. class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override int reduce() => state + amount; } /// This widget is a connector. /// It connects the store to [MyHomePage] (the dumb-widget). /// Each time the state changes, it creates a view-model, and compares it /// with the view-model created with the previous state. /// Only if the view-model changed, the connector rebuilds. class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, onIncrement: vm.onIncrement, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() => ViewModel( counter: state, onIncrement: () => dispatch(IncrementAction(amount: 1)), ); } /// A view-model is a helper object to a [StoreConnector] widget. It holds the /// part of the Store state the corresponding dumb-widget needs, and may also /// convert this state part into a more convenient format for the dumb-widget /// to work with. /// /// You must implement equals/hashcode for the view-model class to work. /// Otherwise, the [StoreConnector] will think the view-model changed everytime, /// and thus will rebuild everytime. This won't create any visible problems /// to your app, but is inefficient and may be slow. /// /// By extending the [Vm] class you can implement equals/hashcode without /// having to override these methods. Instead, simply list all fields /// (which are not immutable, like functions) to the [equals] parameter /// in the constructor. /// class ViewModel extends Vm { final int counter; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.onIncrement, }) : super(equals: [counter]); } /// This is the "dumb-widget". It has no notion of the store, the state, the /// connector or the view-model. It just gets the parameters it needs to display /// itself, and callbacks it should call when reacting to the user interface. class MyHomePage extends StatelessWidget { final int? counter; final VoidCallback? onIncrement; MyHomePage({ Key? key, this.counter, this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Increment Example (StoreConnector)')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)) ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ); } } ================================================ FILE: example/lib/store_connector_examples/main_wait_action_advanced_1__store_connector.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example shows how to use [WaitAction] in advanced ways. /// For this to work, the [AppState] must have a [wait] field of type [Wait], /// and this field must be in the [AppState.copy] method as a named parameter. /// /// 10 buttons are shown. When a button is clicked it will be /// replaced by a downloaded text description. Each button shows a progress /// indicator while its description is downloading. The screen title shows /// the text "Downloading..." if any of the buttons is currently downloading. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state contains a [wait] object of type [Wait]. @immutable class AppState { final Map descriptions; final Wait wait; AppState({required this.descriptions, required this.wait}); /// The copy method has a named [wait] parameter of type [Wait]. AppState copy({int? counter, Map? descriptions, Wait? wait}) => AppState( descriptions: descriptions ?? this.descriptions, wait: wait ?? this.wait, ); /// The [wait] parameter is instantiated to `Wait()`. static AppState initialState() => AppState( descriptions: {}, wait: Wait(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && descriptions == other.descriptions && wait == other.wait; @override int get hashCode => descriptions.hashCode ^ wait.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } class GetDescriptionAction extends ReduxAction { int index; GetDescriptionAction(this.index); @override Future reduce() async { // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/$index/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; await Future.delayed(const Duration(seconds: 2)); // Adds some more delay. Map newDescriptions = Map.of(state.descriptions); newDescriptions[index] = description; return state.copy(descriptions: newDescriptions); } // The wait starts here. We use the index as a wait-flag reference. @override void before() => dispatch(WaitAction.add(index)); // The wait ends here. We remove the index from the wait-flag references. @override void after() => dispatch(WaitAction.remove(index)); } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => PageVmFactory(this), builder: (BuildContext context, PageViewModel vm) => MyHomePage( onGetDescription: vm.onGetDescription, waiting: vm.waiting, ), ); } } /// Factory that creates a view-model for the StoreConnector. class PageVmFactory extends VmFactory { PageVmFactory(connector) : super(connector); @override PageViewModel fromStore() => PageViewModel( /// If there is any waiting, `state.wait.isWaitingAny` will return true. waiting: state.wait.isWaitingAny, onGetDescription: (int index) => dispatch(GetDescriptionAction(index)), ); } class PageViewModel extends Vm { final bool waiting; final void Function(int) onGetDescription; PageViewModel({ required this.waiting, required this.onGetDescription, }) : super(equals: [waiting]); } /// This widget is a connector. It connects the store to "dumb-widget". class MyItemConnector extends StatelessWidget { final int index; final void Function(int) onGetDescription; MyItemConnector({ required this.index, required this.onGetDescription, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => ItemVmFactory(this), builder: (BuildContext context, ItemViewModel vm) => MyItem( description: vm.description, waiting: vm.waiting, index: index, onGetDescription: onGetDescription, ), ); } } /// Factory that creates a view-model for the StoreConnector. class ItemVmFactory extends VmFactory { ItemVmFactory(connector) : super(connector); @override ItemViewModel fromStore() => ItemViewModel( description: state.descriptions[connector.index] ?? "", /// If index is waiting, `state.wait.isWaiting(index)` returns true. waiting: state.wait.isWaiting(connector.index), ); } class ItemViewModel extends Vm { final String description; final bool waiting; ItemViewModel({ required this.description, required this.waiting, }) : super(equals: [description, waiting]); } class MyItem extends StatelessWidget { final String description; final bool waiting; final int index; final void Function(int) onGetDescription; MyItem({ required this.description, required this.waiting, required this.index, required this.onGetDescription, }); @override Widget build(BuildContext context) { Widget contents; if (waiting) contents = _progressIndicator(); else if (description.isNotEmpty) contents = _indexDescription(); else contents = _button(); return Container(height: 70, child: Center(child: contents)); } MaterialButton _button() => MaterialButton( color: Colors.blue, child: Text("CLICK $index", style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), onPressed: () => onGetDescription(index), ); Text _indexDescription() => Text(description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center); CircularProgressIndicator _progressIndicator() => const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(Colors.red), ); } class MyHomePage extends StatelessWidget { final bool waiting; final void Function(int) onGetDescription; MyHomePage({ Key? key, required this.waiting, required this.onGetDescription, }) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ Scaffold( appBar: AppBar(title: Text(waiting ? "Downloading..." : "Advanced WaitAction Example 1")), body: ListView.builder( itemCount: 10, itemBuilder: (context, index) => MyItemConnector( index: index, onGetDescription: onGetDescription, ), ), ), ], ); } } ================================================ FILE: example/lib/store_connector_examples/main_wait_action_advanced_2__store_connector.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example is the same as the one in `main_wait_action_advanced_1__store_connector.dart`. /// However, instead of only using flags in the [WaitAction], it uses both /// flags and references. /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state contains a [wait] object of type [Wait]. @immutable class AppState { final Map descriptions; final Wait wait; AppState({ required this.descriptions, required this.wait, }); /// The copy method has a named [wait] parameter of type [Wait]. AppState copy({int? counter, Map? descriptions, Wait? wait}) => AppState( descriptions: descriptions ?? this.descriptions, wait: wait ?? this.wait, ); /// The [wait] parameter is instantiated to `Wait()`. static AppState initialState() => AppState( descriptions: {}, wait: Wait(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && descriptions == other.descriptions && wait == other.wait; @override int get hashCode => descriptions.hashCode ^ wait.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp( home: MyHomePageConnector(), )); } class GetDescriptionAction extends ReduxAction { int index; GetDescriptionAction(this.index); @override Future reduce() async { // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/$index/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; await Future.delayed(const Duration(seconds: 2)); // Adds some more delay. Map newDescriptions = Map.of(state.descriptions); newDescriptions[index] = description; return state.copy(descriptions: newDescriptions); } // The wait starts here. We use the index as a wait-flag reference. @override void before() => dispatch(WaitAction.add("button-download", ref: index)); // The wait ends here. We remove the index from the wait-flag references. @override void after() => dispatch(WaitAction.remove("button-download", ref: index)); } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => PageVmFactory(this), builder: (BuildContext context, PageViewModel vm) => MyHomePage( onGetDescription: vm.onGetDescription, waiting: vm.waiting, ), ); } } /// Factory that creates a view-model for the StoreConnector. class PageVmFactory extends VmFactory { PageVmFactory(connector) : super(connector); @override PageViewModel fromStore() => PageViewModel( /// If there is any waiting, `state.wait.isWaitingAny` will return true. waiting: state.wait.isWaitingAny, onGetDescription: (int index) => dispatch(GetDescriptionAction(index)), ); } class PageViewModel extends Vm { final bool waiting; final void Function(int) onGetDescription; PageViewModel({ required this.waiting, required this.onGetDescription, }) : super(equals: [waiting]); } /// This widget is a connector. It connects the store to "dumb-widget". class MyItemConnector extends StatelessWidget { final int index; final void Function(int) onGetDescription; MyItemConnector({ required this.index, required this.onGetDescription, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => ItemVmFactory(this), builder: (BuildContext context, ItemViewModel vm) => MyItem( description: vm.description, waiting: vm.waiting, index: index, onGetDescription: onGetDescription, ), ); } } /// Factory that creates a view-model for the StoreConnector. class ItemVmFactory extends VmFactory { ItemVmFactory(connector) : super(connector); @override ItemViewModel fromStore() => ItemViewModel( description: state.descriptions[connector.index] ?? "", /// If index is waiting, `state.wait.isWaiting(index)` returns true. waiting: state.wait.isWaiting("button-download", ref: connector.index), ); } /// The view-model holds the part of the Store state the dumb-widget needs. class ItemViewModel extends Vm { final String description; final bool waiting; ItemViewModel({ required this.description, required this.waiting, }) : super(equals: [description, waiting]); } class MyItem extends StatelessWidget { final String description; final bool waiting; final int index; final void Function(int) onGetDescription; MyItem({ required this.description, required this.waiting, required this.index, required this.onGetDescription, }); @override Widget build(BuildContext context) { Widget contents; if (waiting) contents = _progressIndicator(); else if (description.isNotEmpty) contents = _indexDescription(); else contents = _button(); return Container(height: 70, child: Center(child: contents)); } MaterialButton _button() => MaterialButton( color: Colors.blue, child: Text("CLICK $index", style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), onPressed: () => onGetDescription(index), ); Text _indexDescription() => Text(description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center); CircularProgressIndicator _progressIndicator() => const CircularProgressIndicator( valueColor: AlwaysStoppedAnimation(Colors.red), ); } class MyHomePage extends StatelessWidget { final bool waiting; final void Function(int) onGetDescription; MyHomePage({ Key? key, required this.waiting, required this.onGetDescription, }) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ Scaffold( appBar: AppBar(title: Text(waiting ? "Downloading..." : "Advanced WaitAction Example 2")), body: ListView.builder( itemCount: 10, itemBuilder: (context, index) => MyItemConnector( index: index, onGetDescription: onGetDescription, ), ), ), ], ); } } ================================================ FILE: example/lib/store_connector_examples/main_wait_action_simple__store_connector.dart ================================================ import 'dart:async'; import 'dart:convert'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late Store store; /// This example is the same as the one in `main_before_and_after.dart`. /// However, instead of declaring a `MyWaitAction`, it uses the build-in /// [WaitAction]. /// /// For this to work, the [AppState] must have a [wait] field of type [Wait], /// and this field must be in the [AppState.copy] method as a named parameter. /// /// While the async process is running, the action's `before` method will /// add the action itself as a wait-flag reference: /// /// ``` /// void before() => dispatch(WaitAction.add(this)); /// ``` /// /// The [ViewModel] will read this info from `state.wait.isWaitingAny` to /// turn on the modal barrier. /// /// When the async process finishes, the action's before method will /// remove the action from the wait-flag set: /// /// ``` /// void after() => dispatch(WaitAction.remove(this)); /// ``` /// void main() { var state = AppState.initialState(); store = Store(initialState: state); runApp(MyApp()); } /// The app state contains a [wait] object of type [Wait]. @immutable class AppState { final int counter; final String description; final Wait wait; AppState({ required this.counter, required this.description, required this.wait, }); /// The copy method has a named [wait] parameter of type [Wait]. AppState copy({int? counter, String? description, Wait? wait}) => AppState( counter: counter ?? this.counter, description: description ?? this.description, wait: wait ?? this.wait, ); /// The [wait] parameter is instantiated to `Wait()`. static AppState initialState() => AppState( counter: 0, description: "", wait: Wait(), ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && counter == other.counter && description == other.description && wait == other.wait; @override int get hashCode => counter.hashCode ^ description.hashCode ^ wait.hashCode; } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => StoreProvider( store: store, child: MaterialApp(home: MyHomePageConnector()), ); } /// Use it like this: /// `class MyAction extends ReduxAction with WithWaitState` mixin WithWaitState implements ReduxAction { // Wait starts here. Add the action itself (`this`) as a wait-flag reference. @override void before() => dispatch(WaitAction.add(this)); // Wait ends here. Remove the action from the wait-flag references. @override void after() => dispatch(WaitAction.remove(this)); } class IncrementAndGetDescriptionAction extends ReduxAction with WithWaitState { @override Future reduce() async { dispatch(IncrementAction(amount: 1)); // Then, we start and wait for some asynchronous process. Response response = await get( Uri.parse("https://swapi.dev/api/people/${state.counter}/"), ); Map json = jsonDecode(response.body); String description = json['name'] ?? 'Unknown character'; return state.copy(description: description); } } class IncrementAction extends ReduxAction { final int amount; IncrementAction({required this.amount}); @override AppState reduce() => state.copy(counter: state.counter + amount); } /// This widget is a connector. It connects the store to "dumb-widget". class MyHomePageConnector extends StatelessWidget { MyHomePageConnector({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), builder: (BuildContext context, ViewModel vm) => MyHomePage( counter: vm.counter, description: vm.description, onIncrement: vm.onIncrement, isWaiting: vm.isWaiting, ), ); } } /// Factory that creates a view-model for the StoreConnector. class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() => ViewModel( counter: state.counter, description: state.description, /// While action `IncrementAndGetDescriptionAction` is running, /// [isWaiting] will be true. isWaiting: state.wait.isWaitingForType(), onIncrement: () => dispatch(IncrementAndGetDescriptionAction()), ); } /// The view-model holds the part of the Store state the dumb-widget needs. class ViewModel extends Vm { final int counter; final String description; final bool isWaiting; final VoidCallback onIncrement; ViewModel({ required this.counter, required this.description, required this.isWaiting, required this.onIncrement, }) : super(equals: [counter, description, isWaiting]); } class MyHomePage extends StatelessWidget { final int counter; final String description; final bool isWaiting; final VoidCallback onIncrement; MyHomePage({ Key? key, required this.counter, required this.description, required this.isWaiting, required this.onIncrement, }) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ Scaffold( appBar: AppBar(title: const Text('Wait Action Example')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('You have pushed the button this many times:'), Text('$counter', style: const TextStyle(fontSize: 30)), Text(description, style: const TextStyle(fontSize: 15), textAlign: TextAlign.center), ], ), ), floatingActionButton: FloatingActionButton( onPressed: onIncrement, child: const Icon(Icons.add), ), ), if (isWaiting) ModalBarrier(color: Colors.red.withAlpha((255.0 * 0.4).round())), ], ); } } ================================================ FILE: example/pubspec.yaml ================================================ name: example description: Examples for async_redux. publish_to: "none" version: 1.0.0+1 environment: sdk: '>=3.0.0 <4.0.0' flutter: ">=3.16.0" dependencies: http: ^1.1.0 async_redux: path: ../ flutter: sdk: flutter shared_preferences: ^2.5.4 fast_immutable_collections: ^11.1.0 dev_dependencies: test: ^1.16.0 flutter_test: sdk: flutter flutter: uses-material-design: true ================================================ FILE: example/web/index.html ================================================ example ================================================ FILE: example/web/manifest.json ================================================ { "name": "example", "short_name": "example", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", "description": "A new Flutter project.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ { "src": "icons/Icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "icons/Icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "icons/Icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] } ================================================ FILE: example/windows/.gitignore ================================================ flutter/ephemeral/ # Visual Studio user-specific files. *.suo *.user *.userosscache *.sln.docstates # Visual Studio build-related files. x64/ x86/ # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ ================================================ FILE: example/windows/CMakeLists.txt ================================================ # Project-level configuration. cmake_minimum_required(VERSION 3.14) project(example LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. set(BINARY_NAME "example") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. cmake_policy(VERSION 3.14...3.25) # Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" CACHE STRING "" FORCE) else() if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE "Debug" CACHE STRING "Flutter build mode" FORCE) set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Profile" "Release") endif() endif() # Define settings for the Profile build mode. set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") # Use Unicode for all projects. add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. # # Be cautious about adding new options here, as plugins use this function by # default. In most cases, you should add new options to specific targets instead # of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() # Flutter library and tool build rules. set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) # Application build; see runner/CMakeLists.txt. add_subdirectory("runner") # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) # === Installation === # Support files are copied into place next to the executable, so that it can # run in place. This is done instead of making a separate bundle (as on Linux) # so that building and running from within Visual Studio will work. set(BUILD_BUNDLE_DIR "$") # Make the "install" step default, as it's required to run. set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) endif() set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" COMPONENT Runtime) install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) if(PLUGIN_BUNDLED_LIBRARIES) install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) endif() # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") install(DIRECTORY "${NATIVE_ASSETS_DIR}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" COMPONENT Runtime) # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") install(CODE " file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") " COMPONENT Runtime) install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) # Install the AOT library on non-Debug builds only. install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" CONFIGURATIONS Profile;Release COMPONENT Runtime) ================================================ FILE: example/windows/flutter/CMakeLists.txt ================================================ # This file controls Flutter-level build steps. It should not be edited. cmake_minimum_required(VERSION 3.14) set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") # Configuration provided via flutter tool. include(${EPHEMERAL_DIR}/generated_config.cmake) # TODO: Move the rest of this into files in ephemeral. See # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") # Set fallback configurations for older versions of the flutter tool. if (NOT DEFINED FLUTTER_TARGET_PLATFORM) set(FLUTTER_TARGET_PLATFORM "windows-x64") endif() # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") # Published to parent scope for install step. set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) list(APPEND FLUTTER_LIBRARY_HEADERS "flutter_export.h" "flutter_windows.h" "flutter_messenger.h" "flutter_plugin_registrar.h" "flutter_texture_registrar.h" ) list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") add_library(flutter INTERFACE) target_include_directories(flutter INTERFACE "${EPHEMERAL_DIR}" ) target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") add_dependencies(flutter flutter_assemble) # === Wrapper === list(APPEND CPP_WRAPPER_SOURCES_CORE "core_implementations.cc" "standard_codec.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_PLUGIN "plugin_registrar.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") list(APPEND CPP_WRAPPER_SOURCES_APP "flutter_engine.cc" "flutter_view_controller.cc" ) list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") # Wrapper sources needed for a plugin. add_library(flutter_wrapper_plugin STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ) apply_standard_settings(flutter_wrapper_plugin) set_target_properties(flutter_wrapper_plugin PROPERTIES POSITION_INDEPENDENT_CODE ON) set_target_properties(flutter_wrapper_plugin PROPERTIES CXX_VISIBILITY_PRESET hidden) target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) target_include_directories(flutter_wrapper_plugin PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_plugin flutter_assemble) # Wrapper sources needed for the runner. add_library(flutter_wrapper_app STATIC ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_APP} ) apply_standard_settings(flutter_wrapper_app) target_link_libraries(flutter_wrapper_app PUBLIC flutter) target_include_directories(flutter_wrapper_app PUBLIC "${WRAPPER_ROOT}/include" ) add_dependencies(flutter_wrapper_app flutter_assemble) # === Flutter tool backend === # _phony_ is a non-existent file to force this command to run every time, # since currently there's no way to get a full input/output list from the # flutter tool. set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) add_custom_command( OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ${PHONY_OUTPUT} COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS "${FLUTTER_LIBRARY}" ${FLUTTER_LIBRARY_HEADERS} ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} ${CPP_WRAPPER_SOURCES_APP} ) ================================================ FILE: example/windows/flutter/generated_plugin_registrant.cc ================================================ // // Generated file. Do not edit. // // clang-format off #include "generated_plugin_registrant.h" #include void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); } ================================================ FILE: example/windows/flutter/generated_plugin_registrant.h ================================================ // // Generated file. Do not edit. // // clang-format off #ifndef GENERATED_PLUGIN_REGISTRANT_ #define GENERATED_PLUGIN_REGISTRANT_ #include // Registers Flutter plugins. void RegisterPlugins(flutter::PluginRegistry* registry); #endif // GENERATED_PLUGIN_REGISTRANT_ ================================================ FILE: example/windows/flutter/generated_plugins.cmake ================================================ # # Generated file, do not edit. # list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus ) list(APPEND FLUTTER_FFI_PLUGIN_LIST ) set(PLUGIN_BUNDLED_LIBRARIES) foreach(plugin ${FLUTTER_PLUGIN_LIST}) add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) list(APPEND PLUGIN_BUNDLED_LIBRARIES $) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) endforeach(plugin) foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) endforeach(ffi_plugin) ================================================ FILE: example/windows/runner/CMakeLists.txt ================================================ cmake_minimum_required(VERSION 3.14) project(runner LANGUAGES CXX) # Define the application target. To change its name, change BINARY_NAME in the # top-level CMakeLists.txt, not the value here, or `flutter run` will no longer # work. # # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} WIN32 "flutter_window.cpp" "main.cpp" "utils.cpp" "win32_window.cpp" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "Runner.rc" "runner.exe.manifest" ) # Apply the standard set of build settings. This can be removed for applications # that need different build settings. apply_standard_settings(${BINARY_NAME}) # Add preprocessor definitions for the build version. target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") # Disable Windows macros that collide with C++ standard library functions. target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # Add dependency libraries and include directories. Add any application-specific # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. add_dependencies(${BINARY_NAME} flutter_assemble) ================================================ FILE: example/windows/runner/Runner.rc ================================================ // Microsoft Visual C++ generated resource script. // #pragma code_page(65001) #include "resource.h" #define APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 2 resource. // #include "winres.h" ///////////////////////////////////////////////////////////////////////////// #undef APSTUDIO_READONLY_SYMBOLS ///////////////////////////////////////////////////////////////////////////// // English (United States) resources #if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US #ifdef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // TEXTINCLUDE // 1 TEXTINCLUDE BEGIN "resource.h\0" END 2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END 3 TEXTINCLUDE BEGIN "\r\n" "\0" END #endif // APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Icon // // Icon with lowest ID value placed first to ensure application icon // remains consistent on all systems. IDI_APP_ICON ICON "resources\\app_icon.ico" ///////////////////////////////////////////////////////////////////////////// // // Version // #if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else #define VERSION_AS_NUMBER 1,0,0,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else #define VERSION_AS_STRING "1.0.0" #endif VS_VERSION_INFO VERSIONINFO FILEVERSION VERSION_AS_NUMBER PRODUCTVERSION VERSION_AS_NUMBER FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG #else FILEFLAGS 0x0L #endif FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE 0x0L BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "com.example" "\0" VALUE "FileDescription", "example" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "example" "\0" VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" VALUE "OriginalFilename", "example.exe" "\0" VALUE "ProductName", "example" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1252 END END #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// #ifndef APSTUDIO_INVOKED ///////////////////////////////////////////////////////////////////////////// // // Generated from the TEXTINCLUDE 3 resource. // ///////////////////////////////////////////////////////////////////////////// #endif // not APSTUDIO_INVOKED ================================================ FILE: example/windows/runner/flutter_window.cpp ================================================ #include "flutter_window.h" #include #include "flutter/generated_plugin_registrant.h" FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {} FlutterWindow::~FlutterWindow() {} bool FlutterWindow::OnCreate() { if (!Win32Window::OnCreate()) { return false; } RECT frame = GetClientArea(); // The size here must match the window dimensions to avoid unnecessary surface // creation / destruction in the startup path. flutter_controller_ = std::make_unique( frame.right - frame.left, frame.bottom - frame.top, project_); // Ensure that basic setup of the controller was successful. if (!flutter_controller_->engine() || !flutter_controller_->view()) { return false; } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { this->Show(); }); // Flutter can complete the first frame before the "show window" callback is // registered. The following call ensures a frame is pending to ensure the // window is shown. It is a no-op if the first frame hasn't completed yet. flutter_controller_->ForceRedraw(); return true; } void FlutterWindow::OnDestroy() { if (flutter_controller_) { flutter_controller_ = nullptr; } Win32Window::OnDestroy(); } LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam); if (result) { return *result; } } switch (message) { case WM_FONTCHANGE: flutter_controller_->engine()->ReloadSystemFonts(); break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); } ================================================ FILE: example/windows/runner/flutter_window.h ================================================ #ifndef RUNNER_FLUTTER_WINDOW_H_ #define RUNNER_FLUTTER_WINDOW_H_ #include #include #include #include "win32_window.h" // A window that does nothing but host a Flutter view. class FlutterWindow : public Win32Window { public: // Creates a new FlutterWindow hosting a Flutter view running |project|. explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); protected: // Win32Window: bool OnCreate() override; void OnDestroy() override; LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept override; private: // The project to run. flutter::DartProject project_; // The Flutter instance hosted by this window. std::unique_ptr flutter_controller_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ ================================================ FILE: example/windows/runner/main.cpp ================================================ #include #include #include #include "flutter_window.h" #include "utils.h" int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { CreateAndAttachConsole(); } // Initialize COM, so that it is available for use in the library and/or // plugins. ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); flutter::DartProject project(L"data"); std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); if (!window.Create(L"example", origin, size)) { return EXIT_FAILURE; } window.SetQuitOnClose(true); ::MSG msg; while (::GetMessage(&msg, nullptr, 0, 0)) { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } ::CoUninitialize(); return EXIT_SUCCESS; } ================================================ FILE: example/windows/runner/resource.h ================================================ //{{NO_DEPENDENCIES}} // Microsoft Visual C++ generated include file. // Used by Runner.rc // #define IDI_APP_ICON 101 // Next default values for new objects // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_NEXT_RESOURCE_VALUE 102 #define _APS_NEXT_COMMAND_VALUE 40001 #define _APS_NEXT_CONTROL_VALUE 1001 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif ================================================ FILE: example/windows/runner/runner.exe.manifest ================================================ PerMonitorV2 ================================================ FILE: example/windows/runner/utils.cpp ================================================ #include "utils.h" #include #include #include #include #include void CreateAndAttachConsole() { if (::AllocConsole()) { FILE *unused; if (freopen_s(&unused, "CONOUT$", "w", stdout)) { _dup2(_fileno(stdout), 1); } if (freopen_s(&unused, "CONOUT$", "w", stderr)) { _dup2(_fileno(stdout), 2); } std::ios::sync_with_stdio(); FlutterDesktopResyncOutputStreams(); } } std::vector GetCommandLineArguments() { // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. int argc; wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); if (argv == nullptr) { return std::vector(); } std::vector command_line_arguments; // Skip the first argument as it's the binary name. for (int i = 1; i < argc; i++) { command_line_arguments.push_back(Utf8FromUtf16(argv[i])); } ::LocalFree(argv); return command_line_arguments; } std::string Utf8FromUtf16(const wchar_t* utf16_string) { if (utf16_string == nullptr) { return std::string(); } unsigned int target_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, nullptr, 0, nullptr, nullptr) -1; // remove the trailing null character int input_length = (int)wcslen(utf16_string); std::string utf8_string; if (target_length == 0 || target_length > utf8_string.max_size()) { return utf8_string; } utf8_string.resize(target_length); int converted_length = ::WideCharToMultiByte( CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, input_length, utf8_string.data(), target_length, nullptr, nullptr); if (converted_length == 0) { return std::string(); } return utf8_string; } ================================================ FILE: example/windows/runner/utils.h ================================================ #ifndef RUNNER_UTILS_H_ #define RUNNER_UTILS_H_ #include #include // Creates a console for the process, and redirects stdout and stderr to // it for both the runner and the Flutter library. void CreateAndAttachConsole(); // Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string // encoded in UTF-8. Returns an empty std::string on failure. std::string Utf8FromUtf16(const wchar_t* utf16_string); // Gets the command line arguments passed in as a std::vector, // encoded in UTF-8. Returns an empty std::vector on failure. std::vector GetCommandLineArguments(); #endif // RUNNER_UTILS_H_ ================================================ FILE: example/windows/runner/win32_window.cpp ================================================ #include "win32_window.h" #include #include #include "resource.h" namespace { /// Window attribute that enables dark mode window decorations. /// /// Redefined in case the developer's machine has a Windows SDK older than /// version 10.0.22000.0. /// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute #ifndef DWMWA_USE_IMMERSIVE_DARK_MODE #define DWMWA_USE_IMMERSIVE_DARK_MODE 20 #endif constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; /// Registry key for app theme preference. /// /// A value of 0 indicates apps should use dark mode. A non-zero or missing /// value indicates apps should use light mode. constexpr const wchar_t kGetPreferredBrightnessRegKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in // scale factor int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. void EnableFullDpiSupportIfAvailable(HWND hwnd) { HMODULE user32_module = LoadLibraryA("User32.dll"); if (!user32_module) { return; } auto enable_non_client_dpi_scaling = reinterpret_cast( GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); } FreeLibrary(user32_module); } } // namespace // Manages the Win32Window's window class registration. class WindowClassRegistrar { public: ~WindowClassRegistrar() = default; // Returns the singleton registrar instance. static WindowClassRegistrar* GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); } return instance_; } // Returns the name of the window class, registering the class if it hasn't // previously been registered. const wchar_t* GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); private: WindowClassRegistrar() = default; static WindowClassRegistrar* instance_; bool class_registered_ = false; }; WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; const wchar_t* WindowClassRegistrar::GetWindowClass() { if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); window_class.lpszClassName = kWindowClassName; window_class.style = CS_HREDRAW | CS_VREDRAW; window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); window_class.hIcon = LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; RegisterClass(&window_class); class_registered_ = true; } return kWindowClassName; } void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; } Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } bool Win32Window::Create(const std::wstring& title, const Point& origin, const Size& size) { Destroy(); const wchar_t* window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), static_cast(origin.y)}; HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); double scale_factor = dpi / 96.0; HWND window = CreateWindow( window_class, title.c_str(), WS_OVERLAPPEDWINDOW, Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), Scale(size.width, scale_factor), Scale(size.height, scale_factor), nullptr, nullptr, GetModuleHandle(nullptr), this); if (!window) { return false; } UpdateTheme(window); return OnCreate(); } bool Win32Window::Show() { return ShowWindow(window_handle_, SW_SHOWNORMAL); } // static LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { auto window_struct = reinterpret_cast(lparam); SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); that->window_handle_ = window; } else if (Win32Window* that = GetThisFromHandle(window)) { return that->MessageHandler(window, message, wparam, lparam); } return DefWindowProc(window, message, wparam, lparam); } LRESULT Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { case WM_DESTROY: window_handle_ = nullptr; Destroy(); if (quit_on_close_) { PostQuitMessage(0); } return 0; case WM_DPICHANGED: { auto newRectSize = reinterpret_cast(lparam); LONG newWidth = newRectSize->right - newRectSize->left; LONG newHeight = newRectSize->bottom - newRectSize->top; SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, newHeight, SWP_NOZORDER | SWP_NOACTIVATE); return 0; } case WM_SIZE: { RECT rect = GetClientArea(); if (child_content_ != nullptr) { // Size and position the child window. MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, TRUE); } return 0; } case WM_ACTIVATE: if (child_content_ != nullptr) { SetFocus(child_content_); } return 0; case WM_DWMCOLORIZATIONCOLORCHANGED: UpdateTheme(hwnd); return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); } void Win32Window::Destroy() { OnDestroy(); if (window_handle_) { DestroyWindow(window_handle_); window_handle_ = nullptr; } if (g_active_window_count == 0) { WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); } } Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { return reinterpret_cast( GetWindowLongPtr(window, GWLP_USERDATA)); } void Win32Window::SetChildContent(HWND content) { child_content_ = content; SetParent(content, window_handle_); RECT frame = GetClientArea(); MoveWindow(content, frame.left, frame.top, frame.right - frame.left, frame.bottom - frame.top, true); SetFocus(child_content_); } RECT Win32Window::GetClientArea() { RECT frame; GetClientRect(window_handle_, &frame); return frame; } HWND Win32Window::GetHandle() { return window_handle_; } void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; } bool Win32Window::OnCreate() { // No-op; provided for subclasses. return true; } void Win32Window::OnDestroy() { // No-op; provided for subclasses. } void Win32Window::UpdateTheme(HWND const window) { DWORD light_mode; DWORD light_mode_size = sizeof(light_mode); LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, &light_mode, &light_mode_size); if (result == ERROR_SUCCESS) { BOOL enable_dark_mode = light_mode == 0; DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, &enable_dark_mode, sizeof(enable_dark_mode)); } } ================================================ FILE: example/windows/runner/win32_window.h ================================================ #ifndef RUNNER_WIN32_WINDOW_H_ #define RUNNER_WIN32_WINDOW_H_ #include #include #include #include // A class abstraction for a high DPI-aware Win32 Window. Intended to be // inherited from by classes that wish to specialize with custom // rendering and input handling class Win32Window { public: struct Point { unsigned int x; unsigned int y; Point(unsigned int x, unsigned int y) : x(x), y(y) {} }; struct Size { unsigned int width; unsigned int height; Size(unsigned int width, unsigned int height) : width(width), height(height) {} }; Win32Window(); virtual ~Win32Window(); // Creates a win32 window with |title| that is positioned and sized using // |origin| and |size|. New windows are created on the default monitor. Window // sizes are specified to the OS in physical pixels, hence to ensure a // consistent size this function will scale the inputted width and height as // as appropriate for the default monitor. The window is invisible until // |Show| is called. Returns true if the window was created successfully. bool Create(const std::wstring& title, const Point& origin, const Size& size); // Show the current window. Returns true if the window was successfully shown. bool Show(); // Release OS resources associated with window. void Destroy(); // Inserts |content| into the window tree. void SetChildContent(HWND content); // Returns the backing Window handle to enable clients to set icon and other // window properties. Returns nullptr if the window has been destroyed. HWND GetHandle(); // If true, closing this window will quit the application. void SetQuitOnClose(bool quit_on_close); // Return a RECT representing the bounds of the current client area. RECT GetClientArea(); protected: // Processes and route salient window messages for mouse handling, // size change and DPI. Delegates handling of these to member overloads that // inheriting classes can handle. virtual LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Called when CreateAndShow is called, allowing subclass window-related // setup. Subclasses should return false if setup fails. virtual bool OnCreate(); // Called when Destroy is called. virtual void OnDestroy(); private: friend class WindowClassRegistrar; // OS callback called by message pump. Handles the WM_NCCREATE message which // is passed when the non-client area is being created and enables automatic // non-client DPI scaling so that the non-client area automatically // responds to changes in DPI. All other messages are handled by // MessageHandler. static LRESULT CALLBACK WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept; // Retrieves a class instance pointer for |window| static Win32Window* GetThisFromHandle(HWND const window) noexcept; // Update the window frame's theme to match the system theme. static void UpdateTheme(HWND const window); bool quit_on_close_ = false; // window handle for top level window. HWND window_handle_ = nullptr; // window handle for hosted content. HWND child_content_ = nullptr; }; #endif // RUNNER_WIN32_WINDOW_H_ ================================================ FILE: lib/async_redux.dart ================================================ export 'package:async_redux_core/async_redux_core.dart'; export 'src/action_mixins.dart'; export 'src/action_observer.dart'; export 'src/advanced_user_exception.dart'; export 'src/cache.dart'; export 'src/cloud_sync.dart'; export 'src/connection_exception.dart'; export 'src/error_observer.dart'; export 'src/event_redux.dart'; export 'src/global_wrap_error.dart'; export 'src/log.dart'; export 'src/mock_build_context.dart'; export 'src/mock_store.dart'; export 'src/model_observer.dart'; export 'src/navigate_action.dart'; export 'src/persistor.dart'; export 'src/state_observer.dart'; export 'src/store.dart'; export 'src/store_exception.dart'; export 'src/store_provider_and_connector.dart'; export 'src/store_tester.dart'; export 'src/test_info.dart'; export 'src/user_exception_dialog.dart'; export 'src/view_model.dart'; export 'src/wait.dart'; export 'src/wait_action.dart'; export 'src/wrap_reduce.dart'; ================================================ FILE: lib/local_json_persist.dart ================================================ export 'src/local_json_persist.dart'; ================================================ FILE: lib/local_persist.dart ================================================ export 'src/local_persist.dart'; ================================================ FILE: lib/src/action_mixins.dart ================================================ import 'dart:async'; import 'dart:math'; import 'package:async_redux/async_redux.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:meta/meta.dart'; /// Mixin [CheckInternet] can be used to check if there is internet when you /// run some action that needs internet connection. Just add `with CheckInternet` /// to your action. For example: /// /// ```dart /// class LoadText extends ReduxAction with CheckInternet { /// Future reduce() async { /// /// Response response = await get(Uri.parse("https://swapi.dev/api/people/42/")); /// Map json = jsonDecode(response.body); /// return json['name'] ?? 'Unknown'; /// } /// } /// ``` /// /// It will automatically check if there is internet before running the action. /// If there is no internet, the action will fail, stop executing, and will /// show a dialog to the user with title: /// 'There is no Internet' and content: 'Please, verify your connection.'. /// /// Also, you can display some information in your widgets when the action fails: /// /// ```dart /// if (context.isFailed(LoadText)) Text('No Internet connection'); /// ``` /// /// Or you can use the exception text itself: /// ```dart /// if (context.isFailed(LoadText)) Text(context.exceptionFor(LoadText)?.errorText ?? 'No Internet connection'); /// ``` /// /// If you don't want the dialog to open, you can add the [NoDialog] mixin. /// /// If you want to customize the dialog or the `errorText`, you can override the /// method [connectionException] and return a [UserException] with the desired /// message. /// /// IMPORTANT: It only checks if the internet is on or off on the device, /// not if the internet provider is really providing the service or if the /// server is available. So, it is possible that the check succeeds /// but internet requests still fail. /// /// Notes: /// - This mixin can safely be combined with [NonReentrant] or [Throttle] (not both). /// - It should not be combined with other mixins or classes that override [before]. /// - It should not be combined with other mixins or classes that check the internet connection. /// - It should not be combined with [AbortWhenNoInternet] and [UnlimitedRetryCheckInternet]. /// /// See also: /// * [NoDialog] - To just show a message in your widget, and not open a dialog. /// * [AbortWhenNoInternet] - If you want to silently abort the action when there is no internet. /// mixin CheckInternet on ReduxAction { bool get ifOpenDialog => true; UserException connectionException(List result) => ConnectionException.noConnectivity; /// If you are running tests, you can override this getter to simulate the /// internet connection as on or off: /// /// - Return `true` if there IS internet. /// - Return `false` if there is NO internet. /// - Return `null` to use the real internet connection status (default). /// /// If you want to change this for all actions using mixins [CheckInternet], /// [AbortWhenNoInternet], and [UnlimitedRetryCheckInternet], you can /// do that at the store level: /// /// ```dart /// store.forceInternetOnOffSimulation = () => false; /// ``` /// /// Using [Store.forceInternetOnOffSimulation] is also useful during tests, /// for testing what happens when you have no internet connection. And since /// it's tied to the store, it automatically resets when the store is /// recreated. /// bool? get internetOnOffSimulation => store.forceInternetOnOffSimulation(); Future> checkConnectivity() async { if (internetOnOffSimulation != null) return internetOnOffSimulation! ? [ConnectivityResult.wifi] : [ConnectivityResult.none]; return await (Connectivity().checkConnectivity()); } @mustCallSuper @override Future before() async { _cannot_combine_mixins_CheckInternet_AbortWhenNoInternet_UnlimitedRetryCheckInternet(); super.before(); var result = await checkConnectivity(); if (result.contains(ConnectivityResult.none)) throw connectionException(result).withDialog(ifOpenDialog); } void _cannot_combine_mixins_CheckInternet_AbortWhenNoInternet_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); } } /// Mixin [NoDialog] can only be applied on [CheckInternet]. Example: /// /// ```dart /// class LoadText extends ReduxAction with CheckInternet, NoDialog { /// Future reduce() async { /// Response response = await get(Uri.parse("https://swapi.dev/api/people/42/")); /// Map json = jsonDecode(response.body); /// return json['name'] ?? 'Unknown'; /// } /// } /// ``` /// /// It will turn off showing a dialog when there is no internet. /// But you can still display some information in your widgets: /// /// ```dart /// if (context.isFailed(LoadText)) Text('No Internet connection'); /// ``` /// /// Or you can use the exception text itself: /// ```dart /// if (context.isFailed(LoadText)) Text(context.exceptionFor(LoadText)?.errorText ?? 'No Internet connection'); /// ``` /// mixin NoDialog on CheckInternet { @override bool get ifOpenDialog => false; } /// Mixin [AbortWhenNoInternet] can be used to check if there is internet when /// you run some action that needs it. If there is no internet, the action will /// abort silently, as if it had never been dispatched. /// /// Just add `with AbortWhenNoInternet` to your action. For example: /// /// ```dart /// class LoadText extends ReduxAction with AbortWhenNoInternet { /// Future reduce() async { /// Response response = await get(Uri.parse("https://swapi.dev/api/people/42/")); /// Map json = jsonDecode(response.body); /// return json['name'] ?? 'Unknown'; /// } /// } /// ``` /// /// IMPORTANT: It only checks if the internet is on or off on the device, not if the internet /// provider is really providing the service or if the server is available. So, it is possible that /// this function returns true and the request still fails. /// /// Notes: /// - This mixin can safely be combined with [NonReentrant] or [Throttle] (not both). /// - It should not be combined with other mixins or classes that override [before]. /// - It should not be combined with other mixins or classes that check the internet connection. /// - It should not be combined with [CheckInternet], [NoDialog], and [UnlimitedRetryCheckInternet]. /// /// See also: /// * [CheckInternet] - If you want to show a dialog to the user when there is no internet. /// * [NoDialog] - To just show a message in your widget, and not open a dialog. /// mixin AbortWhenNoInternet on ReduxAction { // /// If you are running tests, you can override this getter to simulate the /// internet connection as on or off: /// /// - Return `true` if there IS internet. /// - Return `false` if there is NO internet. /// - Return `null` to use the real internet connection status (default). /// /// If you want to change this for all actions using mixins [CheckInternet], /// [AbortWhenNoInternet], and [UnlimitedRetryCheckInternet], you can /// do that at the store level: /// /// ```dart /// store.forceInternetOnOffSimulation = () => false; /// ``` /// /// Using [Store.forceInternetOnOffSimulation] is also useful during tests, /// for testing what happens when you have no internet connection. And since /// it's tied to the store, it automatically resets when the store is /// recreated. /// bool? get internetOnOffSimulation => store.forceInternetOnOffSimulation(); Future> checkConnectivity() async { if (internetOnOffSimulation != null) return internetOnOffSimulation! ? [ConnectivityResult.wifi] : [ConnectivityResult.none]; return await (Connectivity().checkConnectivity()); } @mustCallSuper @override Future before() async { _cannot_combine_mixins_CheckInternet_AbortWhenNoInternet_UnlimitedRetryCheckInternet(); super.before(); var result = await checkConnectivity(); if (result.contains(ConnectivityResult.none)) throw AbortDispatchException(); } void _cannot_combine_mixins_CheckInternet_AbortWhenNoInternet_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); } } /// Mixin [NonReentrant] can be used to abort the action in case the action /// is still running from a previous dispatch. Just add `with NonReentrant` /// to your action. For example: /// /// ```dart /// class SaveAction extends ReduxAction with NonReentrant { /// Future reduce() async { /// await http.put('http://myapi.com/save', body: 'data'); /// }} /// ``` /// /// ## Advanced usage /// /// The non-reentrant check is, by default, based on the action [runtimeType]. /// This means it will abort an action if another action of the same runtimeType /// is currently running. If you want to check based on more than simply the /// [runtimeType], you can override the [nonReentrantKeyParams] method. /// For example, here we use a field of the action to differentiate: /// /// ```dart /// class SaveItem extends ReduxAction with NonReentrant { /// final String itemId; /// SaveItem(this.itemId); /// /// Object? nonReentrantKeyParams() => itemId; /// ... /// } /// ``` /// /// With this setup, `SaveItem('A')` and `SaveItem('B')` can run in parallel, /// but two `SaveItem('A')` cannot. /// /// You can also use [computeNonReentrantKey] if you want different action types /// to share the same non-reentrant key. Check the documentation of that method /// for more information. /// /// Notes: /// - This mixin can safely be combined with [CheckInternet], [NoDialog], and [AbortWhenNoInternet]. /// - It should not be combined with other mixins or classes that override [abortDispatch] or [after]. /// - It should not be combined with [Throttle], [UnlimitedRetryCheckInternet], or [Fresh]. /// mixin NonReentrant on ReduxAction { // /// By default the non-reentrant key is based on the action [runtimeType]. /// Override [nonReentrantKeyParams] so that actions of the SAME TYPE /// but with different parameters do not block each other. /// /// For example: /// /// ```dart /// class SaveItem extends ReduxAction with NonReentrant { /// final String itemId; /// SaveItem(this.itemId); /// /// Object? nonReentrantKeyParams() => itemId; /// ... /// } /// ``` /// /// Now `SaveItem('A')` and `SaveItem('B')` can run in parallel, /// but two concurrent dispatches of `SaveItem('A')` will not both run. /// Object? nonReentrantKeyParams() => null; /// By default the non-reentrant key combines the action [runtimeType] /// with [nonReentrantKeyParams]. Override this method if you want /// different action types to share the same non-reentrant key. /// /// ```dart /// class SaveUser extends ReduxAction with NonReentrant { /// final String oderId; /// SaveUser(this.oderId); /// /// Object? computeNonReentrantKey() => orderId; /// ... /// } /// /// class DeleteUser extends ReduxAction with NonReentrant { /// final String oderId; /// DeleteUser(this.oderId); /// /// Object? computeNonReentrantKey() => orderId; /// ... /// } /// ``` /// /// With this setup, `SaveUser('123')` and `DeleteUser('123')` cannot run /// at the same time because they share the same key. /// Object computeNonReentrantKey() => (runtimeType, nonReentrantKeyParams()); /// The set of keys that are currently running. Set get _nonReentrantKeySet => store.internalMixinProps.nonReentrantKeySet; Object? _nonReentrantKey; @override bool abortDispatch() { _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet(); // This mixin should not be combined with other mixins or classes that // set `abortDispatch`, but just in case, we call super first, and we // only set the lock if `super.abortDispatch()` does not want to abort. // // In the code `class MyAction extends AppAction with NonReentrant, OtherMixin` // the order of execution is: // // 1. MyAction.abortDispatch() // 2. OtherMixin.abortDispatch() // 3. NonReentrant.abortDispatch() // 4. AppAction.abortDispatch() // // In other words, any mixin or base class that runs `abortDispatch` // before `NonReentrant` (in the example `MyAction` and `OtherMixin`) // should only call `NonReentrant`'s `abortDispatch` if it wants to proceed. // If the mixin or base class wants to abort (return true), it should not // call NonReentrant with `super.abortDispatch()`. // // For example, this is wrong for `MyAction` or `OtherMixin`: // ```dart // bool abortDispatch() { // // // Wrong: Always calls NonReentrant's abortDispatch // if (super.abortDispatch()) return true; // // bool otherConditions = ... // return otherConditions; // } // ``` // And this is right: // ```dart // bool abortDispatch() { // // bool otherConditions = ... // if (otherConditions) return true; // // // Last thing (NonReentrant's abortDispatch is conditionally called) // return super.abortDispatch(); // } // ``` if (super.abortDispatch()) return true; _nonReentrantKey = computeNonReentrantKey(); // If the key is already in the set, abort. if (_nonReentrantKeySet.contains(_nonReentrantKey)) return true; // // Otherwise, add the key and allow dispatch. else { _nonReentrantKeySet.add(_nonReentrantKey); return false; } } @override void after() { // Remove the key when the action finishes (success or failure). _nonReentrantKeySet.remove(_nonReentrantKey); } void _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [Retry] will retry the [reduce] method if it throws an error. /// Note: If the `before` method throws an error, the retry will NOT happen. /// /// You can override the following parameters: /// /// * [initialDelay]: The delay before the first retry attempt. /// Default is `350` milliseconds. /// /// * [multiplier]: The factor by which the delay increases for each subsequent /// retry. Default is `2`, which means the default delays are: 350 millis, /// 700 millis, and 1.4 seg. /// /// * [maxRetries]: The maximum number of retries before giving up. /// Default is `3`, meaning it will try a total of 4 times. /// /// * [maxDelay]: The maximum delay between retries to avoid excessively long /// wait times. Default is `5` seconds. /// /// If you want to retry unlimited times, you can add the [UnlimitedRetries] mixin. /// /// Note: The retry delay only starts after the reducer finishes executing. For example, /// if the reducer takes 1 second to fail, and the retry delay is 350 millis, the first /// retry will happen 1.35 seconds after the first reducer started. /// /// When the action finally fails (`maxRetries` was reached), /// the last error will be rethrown, and the previous ones will be ignored. /// /// You should NOT combine this with [CheckInternet] or [AbortWhenNoInternet], /// because the retry will not work. /// /// However, for most actions that use [Retry], consider also adding [NonReentrant] to avoid /// multiple instances of the same action running at the same time: /// /// ```dart /// class MyAction extends ReduxAction with Retry, NonReentrant { ... } /// ``` /// /// Keep in mind that all actions using the [Retry] mixin will become asynchronous, /// even if the original action was synchronous. /// /// Notes: /// - Combining [Retry] with [CheckInternet] or [AbortWhenNoInternet] will /// not retry when there is no internet. It will only retry if there IS /// internet but the action fails for some other reason. To retry indefinitely /// until internet is available, use [UnlimitedRetryCheckInternet] instead. /// - It should not be combined with [Debounce], [UnlimitedRetryCheckInternet]. /// - When combined with [OptimisticCommand], the retry logic is handled by /// [OptimisticCommand] to avoid UI flickering. Only the /// [OptimisticCommand.sendCommandToServer] call is retried, keeping the /// optimistic state in place. See [OptimisticCommand] for more details. /// mixin Retry on ReduxAction { // /// The delay before the first retry attempt. Duration get initialDelay => const Duration(milliseconds: 350); /// The factor by which the delay increases for each subsequent retry. /// Must be greater than 1, otherwise it will be set to 2. double get multiplier => 2; /// The maximum number of retries before giving up. /// Must be greater than 0, otherwise it will not retry. /// The total number of attempts is maxRetries + 1. int get maxRetries => 3; /// The maximum delay between retries to avoid excessively long wait times. /// The default is 5 seconds. Duration get maxDelay => const Duration(milliseconds: 5000); int _attempts = 0; /// The number of retry attempts so far. If the action has not been retried yet, it will be 0. /// If the action finished successfully, it will be equal or less than [maxRetries]. /// If the action failed and gave up, it will be equal to [maxRetries] plus 1. int get attempts => _attempts; @override Future wrapReduce(Reducer reduce) async { _cannot_combine_mixins_Debounce_Retry_UnlimitedRetryCheckInternet(); _cannot_combine_mixins_Retry_UnlimitedRetryCheckInternet_OptimisticSync_OptimisticSyncWithPush_ServerPush(); // When combined with OptimisticCommand, we skip the retry logic here. // OptimisticCommand will handle retries internally to avoid UI flickering. // See OptimisticCommand.reduce for details. if (this is OptimisticCommand) { FutureOr newState = reduce(); if (newState is Future) newState = await newState; return newState; } FutureOr newState; try { await microtask; newState = reduce(); if (newState is Future) newState = await newState; } // catch (error) { _attempts++; if ((maxRetries >= 0) && (_attempts > maxRetries)) rethrow; var currentDelay = nextDelay(); await Future.delayed(currentDelay); // Retry the action. return wrapReduce(reduce); } return newState; } Duration? _currentDelay; /// Start with the [initialDelay], and then increase it by [multiplier] each time this is called. /// If the delay exceeds [maxDelay], it will be set to [maxDelay]. Duration nextDelay() { var _multiplier = multiplier; if (_multiplier <= 1) _multiplier = 2; _currentDelay = (_currentDelay == null) // ? initialDelay // : _currentDelay! * _multiplier; if (_currentDelay! > maxDelay) _currentDelay = maxDelay; return _currentDelay!; } void _cannot_combine_mixins_Debounce_Retry_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_Retry_UnlimitedRetryCheckInternet_OptimisticSync_OptimisticSyncWithPush_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [UnlimitedRetries] can be added to the [Retry] mixin, to retry /// indefinitely: /// /// ```dart /// class MyAction extends ReduxAction with Retry, UnlimitedRetries { ... } /// ``` /// /// This is the same as setting [maxRetries] to -1. /// /// Note: If you `await dispatchAndWait(action)` and the action uses [UnlimitedRetries], /// it may never finish if it keeps failing. So, be careful when using it. /// mixin UnlimitedRetries on Retry { @override int get maxRetries => -1; } /// Mixin [OptimisticCommand] is for actions that represent a command. /// A command is something you want to run on the server once per dispatch. /// Typical examples are: /// /// * Create something (add todo, create comment, send message) /// * Delete something /// * Submit a form /// * Upload a file /// * Checkout, place order, confirm payment /// /// This mixin gives fast UI feedback by applying an optimistic state change /// immediately, then running the command on the server, and optionally rolling /// back and reloading. /// /// /// ## When to use the `OptimisticSync` mixin instead /// /// Use [OptimisticSync] or [OptimisticSyncWithPush] when the action is a save /// operation, meaning only the final value matters and intermediate values /// can be skipped. Typical examples are: /// /// * Like or follow toggle /// * Settings switch /// * Slider, checkbox /// * Update a field where the last value wins /// /// In save operations, users may tap many times quickly. `OptimisticSync` is /// built for that and will coalesce rapid changes into a minimal number of /// server calls. [OptimisticCommand] is not built for that. /// /// /// ## The problem /// /// Let's use a Todo app as an example. We want to save a new Todo to a /// TodoList. This code saves the Todo, then reloads the TodoList from the cloud: /// /// ```dart /// class SaveTodo extends ReduxAction { /// final Todo newTodo; /// SaveTodo(this.newTodo); /// /// Future reduce() async { /// try { /// // Saves the new Todo to the cloud. /// await saveTodo(newTodo); /// } finally { /// // Loads the complete TodoList from the cloud. /// var reloadedTodoList = await loadTodoList(); /// return state.copy(todoList: reloadedTodoList); /// } /// } /// } /// ``` /// /// The problem with the above code is that it may take a second to update the /// todoList on screen, while we save then load. /// /// The solution is to optimistically update the TodoList before saving: /// /// ```dart /// class SaveTodo extends ReduxAction { /// final Todo newTodo; /// SaveTodo(this.newTodo); /// /// Future reduce() async { /// // Updates the TodoList optimistically. /// dispatch(UpdateStateAction((state) /// => state.copy(todoList: state.todoList.add(newTodo)))); /// /// try { /// // Saves the new Todo to the cloud. /// await saveTodo(newTodo); /// } finally { /// // Loads the complete TodoList from the cloud. /// var reloadedTodoList = await loadTodoList(); /// return state.copy(todoList: reloadedTodoList); /// } /// } /// } /// ``` /// /// That's better. But if saving fails, users still have to wait for the reload /// until they see the reverted state. We can further improve this: /// /// ```dart /// class SaveTodo extends ReduxAction { /// final Todo newTodo; /// SaveTodo(this.newTodo); /// /// Future reduce() async { /// // Updates the TodoList optimistically. /// var newTodoList = state.todoList.add(newTodo); /// dispatchState(state.copy(todoList: newTodoList)); /// /// try { /// // Saves the new Todo to the cloud. /// await saveTodo(newTodo); /// } catch (e) { /// // If the state still contains our optimistic update, we rollback. /// // If the state now contains something else, we do not rollback. /// if (state.todoList == newTodoList) { /// return state.copy(todoList: initialState.todoList); // Rollback. /// } /// rethrow; /// } finally { /// // Loads the complete TodoList from the cloud. /// var reloadedTodoList = await loadTodoList(); /// dispatchState(state.copy(todoList: reloadedTodoList)); /// } /// } /// } /// ``` /// /// Now the user sees the rollback immediately after the saving fails. The /// [OptimisticCommand] mixin helps you implement this pattern easily, and /// takes care of the edge cases. /// /// ## How to use this mixin /// /// You must provide: /// * [optimisticValue] returns the optimistic value you want to apply right away /// * [getValueFromState] extracts the current value from a given state /// * [applyValueToState] applies a value to a given state and returns the new state /// * [sendCommandToServer] runs the server command (it may use the action fields) /// * [reloadFromServer] optionally reloads from the server (do not override to skip) /// * [applyReloadResultToState] applies the reload result to the state (the default uses [applyValueToState]) /// /// Important details: /// /// * The optimistic update is applied immediately. /// /// * If [sendCommandToServer] fails, rollback happens only if the current state /// still matches the optimistic value created by this dispatch. The rollback /// restores the value from [initialState]. /// /// * Reload is optional. If implemented, it runs after [sendCommandToServer] /// finishes, only in case of error (this can be changed by overriding /// [shouldReload] to return true). /// /// ### Complete example using the mixin /// /// ```dart /// class SaveTodo extends AppAction with OptimisticCommand { /// final Todo newTodo; /// SaveTodo(this.newTodo); /// /// // The new Todo is going to be optimistically applied to the state, right away. /// @override /// Object? optimisticValue() => newTodo; /// /// // We teach the action how to read the Todo from the state. /// @override /// Object? getValueFromState(AppState state) => state.todoList.getById(newTodo.id); /// /// // Apply the value to the state. /// @override /// AppState applyValueToState(AppState state, Object? value) /// => state.copy(todoList: state.todoList.add(newTodo)); /// /// // Contact the server to send the command (save the Todo). I /// @override /// Future sendCommandToServer(Object? newTodo) async => await saveTodo(newTodo); /// /// // If the server returns a value, we may apply it to the state. /// @override /// AppState applyServerResponseToState(AppState state, Todo todo) /// => state.copy(todoList: state.todoList.add(todo)); /// /// // Reload from the cloud (in case of error). /// @override /// Future reloadFromServer() async => await loadTodo(); /// } /// ``` /// /// /// ## Non-reentrant behavior /// /// [OptimisticCommand] is always non-reentrant. If the same action is /// dispatched while a previous dispatch is still running, the new dispatch /// is aborted. This prevents race conditions such as: /// /// * Conflicting optimistic updates overwriting each other /// * Incorrect rollback behavior (the rollback check may no longer match) /// * Race conditions in the reload phase /// * Server side conflicts from concurrent requests /// /// Your UI should let the user know that the command is in progress, /// so they do not try to dispatch it again until it finishes. That's easy /// to do with AsyncRedux, just check if the action is in progress: /// /// ```dart /// bool isSaving = context.isWaiting(SaveTodo); /// ``` /// /// By default, the non-reentrant check is based on the action [runtimeType]. /// If your action has parameters and you want to allow concurrent dispatches /// for different parameters (for example, saving different items), override /// [nonReentrantKeyParams]. For example: /// /// ```dart /// class SaveTodo extends AppAction with OptimisticCommand { /// final String orderId; /// SaveTodo(this.orderId); /// /// @override /// Object? nonReentrantKeyParams() => orderId; /// ... /// } /// ``` /// /// This allows SaveTodo('A') and SaveTodo('B') to run concurrently, while /// blocking concurrent dispatches of SaveTodo('A') with itself. /// /// This is useful for commands that you **do** want to run in parallel, /// as long as they are for different items. Common examples include: /// /// * Uploading multiple files at the same time (key by fileId) /// * Sending multiple chat messages at the same time (key by clientMessageId) /// * Enqueuing multiple jobs at the same time (key by jobId) /// /// In these cases, each key runs non-reentrantly, but different keys can run /// concurrently. /// /// You can also use [computeNonReentrantKey] if you want different action types /// to share the same non-reentrant key. Check the documentation of that method /// for more information. /// /// /// ## [Retry] /// /// When combined with [Retry], only the [sendCommandToServer] call is retried, /// not the optimistic update or rollback. This prevents UI flickering that /// would otherwise occur if the entire reduce was retried on each attempt. /// /// The optimistic state remains in place during retries, and rollback only /// happens if all retry attempts fail. /// /// /// # [CheckInternet] or [AbortWhenNoInternet] /// /// When combined with [CheckInternet] and [AbortWhenNoInternet], when offline: /// /// * No optimistic state is applied /// * No lock is acquired /// * No server call is attempted /// * The action fails and your dialog shows (for [CheckInternet]) /// /// /// Notes: /// - Equality checks use the == operator. Make sure your value type implements /// == in a way that makes sense for optimistic checks. /// - It can be combined with [Retry], [CheckInternet] and [AbortWhenNoInternet]. /// - It should not be combined with [NonReentrant], [Throttle], [Debounce], /// [Fresh], [UnlimitedRetryCheckInternet], [UnlimitedRetries], /// [OptimisticSync], [OptimisticSyncWithPush], or [ServerPush]. /// /// See also: /// * [OptimisticSync] and [OptimisticSyncWithPush] for save operations. /// mixin OptimisticCommand on ReduxAction { // /// Override this method to return the value that you want to update, and /// that you want to apply optimistically to the state. /// /// You can access the fields of the action, and the current [state], and /// return the new value. /// /// ```dart /// Object? optimisticValue() => newTodo; /// ``` Object? optimisticValue(); /// Using the given [state], you should apply the given [value] to it, and /// return the result. This will be used to apply the optimistic value to /// the state, and also later to rollback, if necessary, by applying the /// initial value. /// /// ```dart /// AppState applyValueToState(state, newTodoList) /// => state.copy(todoList: newTodoList); /// ``` St applyValueToState(St state, Object? value); /// Using the given [state], you should return the current value from that /// state. This is used to check if the state still contains the optimistic /// value, so the mixin knows whether it is safe to rollback. /// /// ```dart /// Object? getValueFromState(AppState state) => state.todoList; /// ``` Object? getValueFromState(St state); /// You should save the [optimisticValue] or other related value in the cloud, /// and optionally return the server's response. /// /// Note: You can ignore [optimisticValue] and use the action fields instead, /// if that makes more sense for your API. /// /// If [sendCommandToServer] returns a non-null value, that value will be /// passed to [applyServerResponseToState] to update the state. /// /// ```dart /// Future sendCommandToServer(newTodoList) async { /// var response = await saveTodo(newTodo); /// return response; // Return server-confirmed value, or null. /// } /// ``` Future sendCommandToServer(Object? optimisticValue); /// Override [applyServerResponseToState] to return a new state, where the /// given [serverResponse] (previously received from the server when running /// [sendCommandToServer]) is applied to the current [state]. Example: /// /// ```dart /// AppState? applyServerResponseToState(state, serverResponse) => /// state.copyWith(todoList: serverResponse.todoList); /// ``` /// /// Note [serverResponse] is never `null` here, because this method is only /// called when [sendCommandToServer] returned a non-null value. /// /// If you decide you DO NOT want to apply the server response to the state, /// simply return `null`. /// St? applyServerResponseToState(St state, Object serverResponse) => null; /// Override to reload the value from the cloud. /// If you want to skip reload, do not override this method. /// /// Note: If you are using a realtime database or WebSockets to receive /// server pushed updates, you may not need to reload here. /// /// ```dart /// Future reloadFromServer() => loadTodoList(); /// ``` Future reloadFromServer() { throw UnimplementedError(); } /// Returns the state to apply when the command fails and the mixin decides /// it is safe to rollback. /// /// This method is called only when: /// * [sendCommandToServer] throws, AND /// * [shouldRollback] returns true. By default it returns true only if the /// current value in the store still matches the optimistic value created /// by this dispatch (so we do not rollback over newer changes). /// /// Parameters: /// /// * [initialValue] is the value extracted from [initialState] using /// [getValueFromState]. It represents what the value was when this action /// was first dispatched. /// /// * [optimisticValue] is the value returned by [optimisticValue] and applied /// optimistically by this dispatch. /// /// * [error] is the error thrown by [sendCommandToServer]. /// /// By default, the mixin restores [initialValue] by calling [applyValueToState]. /// /// Override this method if rollback is not simply "put the old value back". /// For example, you may want to: /// * Keep the optimistic item but mark it as failed. /// * Remove only the item you added, while keeping other local changes. /// * Roll back multiple parts of the state, not just the value handled by /// [applyValueToState]. /// /// Return `null` to skip rollback even when the mixin would normally rollback. /// St? rollbackState({ required Object? initialValue, required Object? optimisticValue, required Object error, }) => applyValueToState(state, initialValue); /// Returns true if the mixin should rollback after [sendCommandToServer] /// fails. This method is called only when [sendCommandToServer] throws. /// /// The default behavior is: /// Rollback only if the current value in the store still matches the /// optimistic value created by this dispatch. This avoids rolling back over /// newer changes that may have happened while the request was in flight. /// /// Override this if you need a different safety rule. For example: /// * You want to always rollback, even if something else changed. /// * You want to rollback only if a specific item is still present. /// * You want to rollback only for some errors. /// bool shouldRollback({ required Object? currentValue, required Object? initialValue, required Object? optimisticValue, required Object error, }) { // Default: rollback only if we are still seeing our own optimistic value. if (currentValue is ImmutableCollection && optimisticValue is ImmutableCollection) { return currentValue.same(optimisticValue); } else { return currentValue == optimisticValue; } } /// Whether the mixin should call [reloadFromServer]. /// /// This method is called in `finally`, both on success and on error, before /// the reload happens. /// /// Parameters: /// * [currentValue] is the value currently in the store (extracted with /// [getValueFromState]) at the moment we are deciding whether to reload. /// /// * [lastAppliedValue] is the last value this action applied for the same /// state slice. It is the optimistic value on success, or the rollback value /// if rollback was applied. /// /// * [optimisticValue] is the value returned by [optimisticValue] and applied /// optimistically by this dispatch. /// /// * [rollbackValue] is `null` if no rollback state was applied. If rollback /// was applied, this is the value extracted from the rollback state using /// [getValueFromState]. /// /// * [error] is `null` on success, or the error thrown by [sendCommandToServer] /// on failure. /// /// Default behavior: /// Returns true, meaning: If [reloadFromServer] is implemented, we reload. /// /// Override this method if you want to skip reloading in some cases. /// For example, reload only on error, or skip reload when the value already /// changed to something else. For example: /// /// ```dart /// bool shouldReload(...) => currentValue == lastAppliedValue; /// bool shouldApplyReload(...) => currentValue == lastAppliedValue; /// ``` /// bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => error != null; /// Returns true if the mixin should apply the result returned by /// [reloadFromServer] to the state. /// /// This method is called after [reloadFromServer] completes, both on success /// and on error. /// /// Parameters are the same as [shouldReload], plus: /// * [reloadResult] is whatever [reloadFromServer] returned. /// /// Default behavior: /// Always apply the reload result. This matches the common expectation that /// if you chose to reload, the server is the source of truth. /// /// Override this method if you want to avoid overwriting newer local changes, /// or if you need custom rules based on [reloadResult] or [error]. /// bool shouldApplyReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? reloadResult, required Object? error, // null on success }) => true; /// Applies the result returned by [reloadFromServer] to the state. /// /// Override this method when [reloadFromServer] returns something that is not /// the same type or shape expected by [applyValueToState], or when applying /// the reload requires updating multiple parts of the state. /// /// Return `null` to ignore the reload result. /// St? applyReloadResultToState(St state, Object? reloadResult) => applyValueToState(state, reloadResult); /// By default the non-reentrant key is based on the action [runtimeType]. /// Override [nonReentrantKeyParams] so that actions of the SAME TYPE /// but with different parameters do not block each other. /// /// For example: /// /// ```dart /// class SaveItem extends AppAction with OptimisticCommand { /// final String itemId; /// SaveItem(this.itemId); /// /// Object? nonReentrantKeyParams() => itemId; /// ... /// } /// ``` /// /// Now `SaveItem('A')` and `SaveItem('B')` can run in parallel, /// but two concurrent dispatches of `SaveItem('A')` will not both run. /// Object? nonReentrantKeyParams() => null; /// By default the non-reentrant key combines the action [runtimeType] /// with [nonReentrantKeyParams]. Override this method if you want /// different action types to share the same non-reentrant key. /// /// ```dart /// class SaveUser extends ReduxAction with OptimisticCommand { /// final String oderId; /// SaveUser(this.oderId); /// /// Object? computeNonReentrantKey() => orderId; /// ... /// } /// /// class DeleteUser extends ReduxAction with OptimisticCommand { /// final String oderId; /// DeleteUser(this.oderId); /// /// Object? computeNonReentrantKey() => orderId; /// ... /// } /// ``` /// /// With this setup, `SaveUser('123')` and `DeleteUser('123')` cannot run /// at the same time because they share the same key. /// Object computeNonReentrantKey() => (runtimeType, nonReentrantKeyParams()); @override Future reduce() async { // Updates the value optimistically. final optimistic = optimisticValue(); dispatchState(applyValueToState(state, optimistic)); Object? commandError; Object? lastAppliedValue = optimistic; // what this action last wrote Object? rollbackValue; // value slice after rollback, if any try { // Saves the new value to the cloud. // If this action also uses the Retry mixin, we handle retries here // to avoid UI flickering (applying/rolling back on each retry attempt). final serverResponse = await _sendCommandWithRetryIfNeeded(optimistic); // Apply server response if not null. if (serverResponse != null) { final St? newState = applyServerResponseToState(state, serverResponse); if (newState != null) { dispatchState(newState); // Keep lastAppliedValue in sync with what we just wrote for the slice. lastAppliedValue = getValueFromState(newState); } } } catch (error) { commandError = error; // Decide if it is safe to rollback (default: only if we are still seeing // our own optimistic value, to avoid undoing newer changes made while // the request was in flight). final currentValue = getValueFromState(state); final initialValue = getValueFromState(initialState); if (shouldRollback( currentValue: currentValue, initialValue: initialValue, optimisticValue: optimistic, error: error, )) { final rollback = rollbackState( initialValue: initialValue, optimisticValue: optimistic, error: error, ); if (rollback != null) { dispatchState(rollback); // Update "lastAppliedValue" to match what rollback wrote for the value slice. rollbackValue = getValueFromState(rollback); lastAppliedValue = rollbackValue; } } rethrow; } finally { try { // Snapshot current value before deciding whether to reload. final Object? currentValueBefore = getValueFromState(state); final bool doReload = shouldReload( currentValue: currentValueBefore, lastAppliedValue: lastAppliedValue, optimisticValue: optimistic, rollbackValue: rollbackValue, error: commandError, // null on success ); if (doReload) { final Object? reloadResult = await reloadFromServer(); // Re-read after await, because state may have changed while reloading. final Object? currentValueAfter = getValueFromState(state); final bool apply = shouldApplyReload( currentValue: currentValueAfter, lastAppliedValue: lastAppliedValue, optimisticValue: optimistic, rollbackValue: rollbackValue, reloadResult: reloadResult, error: commandError, // null on success ); if (apply) { final St? newState = applyReloadResultToState(state, reloadResult); if (newState != null) dispatchState(newState); } } } on UnimplementedError catch (_) { // If reloadFromServer was not implemented, do nothing. } catch (reloadError) { // Important: Do not let reload failure hide the original command error. if (commandError == null) rethrow; } } return null; } /// When combined with Retry, this method retries only the [sendCommandToServer] /// call, keeping the optimistic update in place and avoiding UI flickering. Future _sendCommandWithRetryIfNeeded( Object? _optimisticValue) async { // If this action doesn't use the Retry mixin, // just call sendCommandToServer directly. if (this is! Retry) { return sendCommandToServer(_optimisticValue); } // Access the Retry mixin's properties via casting. final retryMixin = this as Retry; while (true) { try { return await sendCommandToServer(_optimisticValue); } catch (error) { retryMixin._attempts++; // If maxRetries is reached (and not unlimited), rethrow the error. if ((retryMixin.maxRetries >= 0) && (retryMixin._attempts > retryMixin.maxRetries)) { rethrow; } // Wait before retrying. final currentDelay = retryMixin.nextDelay(); await Future.delayed(currentDelay); // Loop continues, retrying sendCommandToServer. } } } /// The set of keys that are currently running. Set get _nonReentrantCommandKeySet => store.internalMixinProps.nonReentrantKeySet; Object? _nonReentrantCommandKey; @override bool abortDispatch() { _cannot_combine_mixins_OptimisticCommand(); _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush(); // First, check the super class/mixin wants to abort. // See the comment in [NonReentrant.abortDispatch]. if (super.abortDispatch()) return true; _nonReentrantCommandKey = computeNonReentrantKey(); // If the key is already in the set, abort. if (_nonReentrantCommandKeySet.contains(_nonReentrantCommandKey)) return true; // // Otherwise, add the key and allow dispatch. else { _nonReentrantCommandKeySet.add(_nonReentrantCommandKey); return false; } } @override void after() { // Remove the key when the action finishes (success or failure). _nonReentrantCommandKeySet.remove(_nonReentrantCommandKey); } /// Only [Retry], [CheckInternet] and [AbortWhenNoInternet] can be combined /// with [OptimisticCommand]. /// void _cannot_combine_mixins_OptimisticCommand() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [Throttle] ensures the action will be dispatched at most once in the /// specified throttle period. It acts as a simple rate limit, so the action /// does not run too often. /// /// If an action is dispatched multiple times within a throttle period, only /// the first dispatch runs and the others are aborted. After the throttle /// period has passed, the next dispatch is allowed to run again, which starts /// a new throttle period. /// /// This is useful when an action may be triggered many times in a short time, /// for example by fast user input or widget rebuilds, but you only want it to /// run from time to time instead of on every dispatch. /// /// For example, if you are using a `StatefulWidget` that needs to load some /// information, you can dispatch the loading action when the widget is /// created in `initState()` and specify a throttle period so that it does not /// reload that information too often: /// /// ```dart /// class MyScreen extends StatefulWidget { /// State createState() => _MyScreenState(); /// } /// /// class _MyScreenState extends State { /// /// void initState() { /// super.initState(); /// context.dispatch(LoadInformation()); // Here! /// } /// /// Widget build(BuildContext context) { /// var information = context.state.information; /// return Text('Information: $information'); /// } /// } /// ``` /// /// and then: /// /// ```dart /// class LoadInformation extends ReduxAction with Throttle { /// /// int throttle = 5000; /// /// Future reduce() async { /// var information = await loadInformation(); /// return state.copy(information: information); /// } /// } /// ``` /// /// The [throttle] value is given in milliseconds, and the default is 1000 /// milliseconds (1 second). You can override this default: /// /// ```dart /// class MyAction extends ReduxAction with Throttle { /// int throttle = 500; // Here! /// ... /// } /// ``` /// /// You can also override [ignoreThrottle] if you want the action to ignore the /// throttle period under some conditions. For example, suppose you want the /// action to provide a flag called `force` that will ignore the throttle /// period: /// /// ```dart /// class MyAction extends ReduxAction with Throttle { /// final bool force; /// MyAction({this.force = false}); /// /// bool get ignoreThrottle => force; // Here! /// /// int throttle = 500; /// ... /// } /// ``` /// /// # If the action fails /// /// The throttle lock is NOT removed if the action fails. This means that if /// the action throws and you dispatch it again within the throttle period, it /// will not run a second time. /// /// If you want, you can specify a different behavior by making /// [removeLockOnError] true, like this: /// /// ```dart /// class MyAction extends ReduxAction with Throttle { /// bool removeLockOnError = true; // Here! /// ... /// } /// ``` /// /// Now, if the action fails, it will remove the lock and allow the action to /// be dispatched again right away. Note, this currently implemented in the /// [after] method, which means you can override it to customize this behavior: /// /// ```dart /// @override /// void after() { /// if (removeLockOnError && (status.originalError != null)) removeLock(); /// } /// ``` /// /// # Advanced usage /// /// The throttle is, by default, based on the action [runtimeType]. This means /// it will throttle an action if another action of the same runtimeType was /// previously dispatched within the throttle period. In other words, the /// runtimeType is the "lock". If you want to throttle based on a different /// lock, you can override the [lockBuilder] method. For example, here /// we throttle two different actions based on the same lock: /// /// ```dart /// class MyAction1 extends ReduxAction with Throttle { /// Object? lockBuilder() => 'myLock'; /// ... /// } /// /// class MyAction2 extends ReduxAction with Throttle { /// Object? lockBuilder() => 'myLock'; /// ... /// } /// ``` /// /// Another example is to throttle based on some field of the action: /// /// ```dart /// class MyAction extends ReduxAction with Throttle { /// final String lock; /// MyAction(this.lock); /// Object? lockBuilder() => lock; /// ... /// } /// ``` /// /// Note: Expired locks are removed when expired, to prevent memory leaks. /// /// Notes: /// - It should not be combined with other mixins or classes that override [abortDispatch] or [after]. /// - It should not be combined with [Fresh], [NonReentrant] or [UnlimitedRetryCheckInternet]. /// mixin Throttle on ReduxAction { // int get throttle => 1000; // Milliseconds bool get removeLockOnError => false; bool get ignoreThrottle => false; /// The default lock for throttling is the action's [runtimeType], /// meaning it will throttle the dispatch of actions of the same type. /// Override this method to customize the lock to any value. /// For example, you can return a string or an enum, and actions with the /// same lock value will throttle each other. /// Note: Expired locks are removed when expired, to prevent memory leaks. Object? lockBuilder() => runtimeType; /// Map that stores the expiry time for each lock. /// The value is the instant when the throttle period ends. Map get _throttleLockMap => store.internalMixinProps.throttleLockMap; /// Removes the lock, allowing an action of the same type to be dispatched /// again right away. You generally do not need to call this method. void removeLock() => _throttleLockMap.remove(lockBuilder()); /// Removes all locks, allowing all actions to be dispatched again right away. /// You generally don't need to call this method. void removeAllLocks() => _throttleLockMap.clear(); @override bool abortDispatch() { _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet(); // First, check the super class/mixin wants to abort. // See the comment in [NonReentrant.abortDispatch]. if (super.abortDispatch()) return true; final lock = lockBuilder(); final now = DateTime.now().toUtc(); // If should ignore the throttle, then set a new expiry and allow dispatch. if (ignoreThrottle) { _throttleLockMap[lock] = _expiringLockFrom(now); return false; } final expiresAt = _throttleLockMap[lock]; // If there is no lock, or it has expired, set a new expiry and allow. if (expiresAt == null || !expiresAt.isAfter(now)) { _throttleLockMap[lock] = _expiringLockFrom(now); return false; } // Still inside the throttle period, abort dispatch. return true; } DateTime _expiringLockFrom(DateTime now) => now.add(Duration(milliseconds: throttle)); /// Remove locks whose expiry time is in the past or now. void _prune() { final now = DateTime.now().toUtc(); _throttleLockMap.removeWhere((_, expiresAt) => !expiresAt.isAfter(now)); } @override void after() { if (removeLockOnError && (status.originalError != null)) removeLock(); _prune(); } void _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [Debounce] delays the execution of a function until after a certain /// period of inactivity. Each time the debounced function is called, /// the period of inactivity (or wait time) is reset. /// /// The function will only execute after it stops being called for the duration /// of the wait time. Debouncing is useful in situations where you want to /// ensure that a function is not called too frequently and only runs after /// some “quiet time.” /// /// For example, it’s commonly used for handling input validation in text fields, /// where you might not want to validate the input every time the user presses /// a key, but rather after they've stopped typing for a certain amount of time. /// /// /// The [debounce] value is given in milliseconds, and the default is 333 /// milliseconds (1/3 of a second). You can override this default: /// /// ```dart /// class MyAction extends ReduxAction with Debounce { /// final int debounce = 1000; // Here! /// ... /// } /// ``` /// /// # Advanced usage /// /// The debounce is, by default, based on the action [runtimeType]. This means /// it will reset the debounce period when another action of the same /// runtimeType was is dispatched within the debounce period. In other words, /// the runtimeType is the "lock". If you want to debounce based on a different /// lock, you can override the [lockBuilder] method. For example, here /// we debounce two different actions based on the same lock: /// /// ```dart /// class MyAction1 extends ReduxAction with Debounce { /// Object? lockBuilder() => 'myLock'; /// ... /// } /// /// class MyAction2 extends ReduxAction with Debounce { /// Object? lockBuilder() => 'myLock'; /// ... /// } /// ``` /// /// Another example is to debounce based on some field of the action: /// /// ```dart /// class MyAction extends ReduxAction with Debounce { /// final String lock; /// MyAction(this.lock); /// Object? lockBuilder() => lock; /// ... /// } /// ``` /// /// Notes: /// - It should not be combined with other mixins or classes that override [wrapReduce]. /// - It should not be combined with [Retry], [UnlimitedRetries], or [UnlimitedRetryCheckInternet]. /// mixin Debounce on ReduxAction { // int get debounce => 333; // Milliseconds /// The default lock for debouncing is the action's [runtimeType], /// meaning it will debounce the dispatch of actions of the same type. /// Override this method to customize the lock to any value. /// For example, you can return a string or an enum, and actions with the /// same lock value will debounce each other. Object? lockBuilder() => runtimeType; /// Map that stores the run-number for actions with a specific lock. Map get _debounceLockMap => store.internalMixinProps.debounceLockMap; // A large number that JavaScript can still represent. // In theory, it could be between -9007199254740991 and 9007199254740991. static const _SAFE_INTEGER = 9000000000000000; /// Removes all locks, allowing all actions to be dispatched again right away. /// You generally don't need to call this method. void removeAllLocks() => _debounceLockMap.clear(); @override Future wrapReduce(Reducer reduce) async { _cannot_combine_mixins_Debounce_Retry_UnlimitedRetryCheckInternet(); var lock = lockBuilder(); // Increment and update the map with the new run count. var before = (_debounceLockMap[lock] ?? 0) + 1; if (before > _SAFE_INTEGER) before = 0; _debounceLockMap[lock] = before; await Future.delayed(Duration(milliseconds: debounce)); var after = _debounceLockMap[lock]; // If the run has changed, it means the action was dispatched again // within the debounce period. So, we abort the reducer. if (after != before) return null; // // Otherwise, we remove the lock and run the reducer. else { _debounceLockMap.remove(lock); return reduce(); } } void _cannot_combine_mixins_Debounce_Retry_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [UnlimitedRetryCheckInternet] can be used to check if there is /// internet when you run some action that needs it. If there is no internet, /// the action will abort silently, and then retry the [reduce] method /// unlimited times, until there is internet. It will also retry if there /// is internet but the action failed. /// /// Just add `with UnlimitedRetryCheckInternet` to your action. /// For example: /// /// ```dart /// class LoadText extends AppAction UnlimitedRetryCheckInternet { /// Future reduce() async { /// Response response = await get(Uri.parse("https://swapi.dev/api/people/42/")); /// Map json = jsonDecode(response.body); /// return json['name'] ?? 'Unknown'; /// } /// } /// ``` /// /// IMPORTANT: This mixin combines [Retry], [UnlimitedRetries], /// [AbortWhenNoInternet] and [NonReentrant] mixins, but there is /// difference. Combining [Retry] with [CheckInternet] or [AbortWhenNoInternet] /// will not retry when there is no internet. It will only retry if there IS /// internet but the action fails for some other reason. To retry indefinitely /// until internet is available, then you should use [UnlimitedRetryCheckInternet]. /// /// IMPORTANT: It only checks if the internet is on or off on the device, /// not if the internet provider is really providing the service or if the /// server is available. So, it is possible that this function returns true /// and the request still fails. /// /// Notes: /// - It should not be combined with other mixins or classes that override [wrapReduce] or [abortDispatch]. /// - It should not be combined with other mixins or classes that check the internet connection. /// - Make sure your `before` method does not throw an error, or the retry will NOT happen. /// - All retries will be printed to the console. /// mixin UnlimitedRetryCheckInternet on ReduxAction { // @override bool abortDispatch() { _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet(); _cannot_combine_mixins_CheckInternet_AbortWhenNoInternet_UnlimitedRetryCheckInternet(); _cannot_combine_mixins_Debounce_Retry_UnlimitedRetryCheckInternet(); _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush(); // First, check the super class/mixin wants to abort. // See the comment in [NonReentrant.abortDispatch]. if (super.abortDispatch()) return true; return isWaiting(runtimeType); } /// The delay before the first retry attempt. Duration get initialDelay => const Duration(milliseconds: 350); /// The factor by which the delay increases for each subsequent retry. /// Must be greater than 1, otherwise it will be set to 2. double get multiplier => 2; /// Unlimited retries. int get maxRetries => -1; /// The maximum delay between retries to avoid excessively long wait times. /// This is for errors that are not related to the Internet. /// The default is 5 seconds. /// See also: [maxDelayNoInternet] Duration get maxDelay => const Duration(milliseconds: 5000); /// The maximum delay between retries when there is no Internet. /// The default is 1 second. /// See also: [maxDelay] Duration get maxDelayNoInternet => const Duration(seconds: 1); int _attempts = 0; /// The number of retry attempts so far. If the action has not been retried yet, it will be 0. /// If the action finished successfully, it will be equal or less than [maxRetries]. /// If the action failed and gave up, it will be equal to [maxRetries] plus 1. int get attempts => _attempts; /// This prints the retries, including the action name, the attempt, and if /// the problem was no Internet or not. To remove the print message, /// override with: /// /// ```dart /// void printRetries(String message) {} /// ``` void printRetries(String message) => print(message); @override Future wrapReduce(Reducer reduce) async { FutureOr newState; bool hasInternet = true; try { // Note we don't have side-effects before the first await. var result = await checkConnectivity(); // IMPORTANT: We throw this exception, but it will not ever be shown, // because we are retrying unlimited times. This simply triggers the next retry. if (result.contains(ConnectivityResult.none)) { hasInternet = false; throw const UserException(''); } if (attempts == 0) printRetries('Trying $runtimeType.'); else printRetries('Retrying $runtimeType (attempt $attempts).'); newState = reduce(); if (newState is Future) newState = await newState; } // catch (error) { // if (!hasInternet) { if (attempts == 0) printRetries('Trying $runtimeType; aborted because of no internet.'); else printRetries( 'Retrying $runtimeType; aborted because of no internet (attempt $attempts).'); } _attempts++; if ((maxRetries >= 0) && (_attempts > maxRetries)) rethrow; var currentDelay = nextDelay(hasInternet: hasInternet); await Future.delayed(currentDelay); return wrapReduce(reduce); } return newState; } Duration? _currentDelay; /// Start with the [initialDelay], and then increase it by [multiplier] each time this is called. /// If the delay exceeds [maxDelay], it will be set to [maxDelay]. Duration nextDelay({required bool hasInternet}) { var _multiplier = multiplier; if (_multiplier <= 1) _multiplier = 2; _currentDelay = (_currentDelay == null) // ? initialDelay // : _currentDelay! * _multiplier; if (hasInternet) { if (_currentDelay! > maxDelay) _currentDelay = maxDelay; } else { if (_currentDelay! > maxDelayNoInternet) _currentDelay = maxDelayNoInternet; } return _currentDelay!; } /// If you are running tests, you can override this getter to simulate the /// internet connection as on or off: /// /// - Return `true` if there IS internet. /// - Return `false` if there is NO internet. /// - Return `null` to use the real internet connection status (default). /// /// If you want to change this for all actions using mixins [CheckInternet], /// [AbortWhenNoInternet], and [UnlimitedRetryCheckInternet], you can /// do that at the store level: /// /// ```dart /// store.forceInternetOnOffSimulation = () => false; /// ``` /// /// Using [Store.forceInternetOnOffSimulation] is also useful during tests, /// for testing what happens when you have no internet connection. And since /// it's tied to the store, it automatically resets when the store is /// recreated. /// bool? get internetOnOffSimulation => store.forceInternetOnOffSimulation(); Future> checkConnectivity() async { if (internetOnOffSimulation != null) return internetOnOffSimulation! ? [ConnectivityResult.wifi] : [ConnectivityResult.none]; return await (Connectivity().checkConnectivity()); } void _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_CheckInternet_AbortWhenNoInternet_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_Debounce_Retry_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [Fresh] lets you treat the result of an action as fresh for a /// given time period. While the information is fresh, repeated dispatches of /// the same action (or other actions with the same "fresh-key") are skipped, /// because that information is assumed to still be valid in the state. /// /// After the fresh period ends, the information is considered "stale". /// The next dispatch of an action with the same "fresh-key" is allowed to /// run again, update the state, and start a new fresh period. /// /// In short, [Fresh] helps you avoid reloading the same information too often. /// /// /// ## Basic usage /// /// This is often used for actions that load information from a server. You can /// think of the fresh period as the time during which the loaded data is still /// good to use. After that time, a new dispatch will reload it. /// /// A simple example in a `StatefulWidget` that loads information once when /// the widget is created: /// /// ```dart /// class MyScreen extends StatefulWidget { /// State createState() => _MyScreenState(); /// } /// /// class _MyScreenState extends State { /// void initState() { /// super.initState(); /// context.dispatch(LoadInformation()); // Here! /// } /// /// Widget build(BuildContext context) { /// var information = context.state.information; /// return Text('Information: $information'); /// } /// } /// ``` /// /// Use [Fresh] on the loading action so it does not run again while its data /// is still fresh: /// /// ```dart /// class LoadInformation extends ReduxAction with Fresh { /// /// Future reduce() async { /// var information = await loadInformation(); /// return state.copy(information: information); /// } /// } /// ``` /// /// /// ## How fresh-keys work /// /// * Dispatched actions with different fresh-keys are not affected. /// /// * Dispatched actions with the same fresh-key: /// - Are aborted while the data is fresh (the fresh period has not passed). /// - Run again when the data is stale (after the fresh period has passed). /// /// In other words, freshness is tracked per fresh-key. Any two dispatches that /// share the same fresh-key share the same fresh period. /// /// By default, the key is based on: /// * The action type (its `runtimeType`), and /// * The value returned by [freshKeyParams]. /// /// In the previous example, the fresh-key of the `LoadInformation` action is /// simply the action [runtimeType], since it did not override [freshKeyParams]. /// /// If you dispatch `LoadInformation` many times in a short period, only the /// first one runs while the data is fresh. The others are aborted. Later, /// when the fresh period ends, the next dispatch will run the action again. /// /// The default [freshKeyParams] returns `null`, so the key is only the action /// type. This means all actions of the same type share the same fresh period, /// and different action types do not affect each other. /// /// ### Using [freshKeyParams] to separate instances /// /// Many actions need a separate fresh period per id, url, or some other field. /// In that case, override [freshKeyParams]. Actions of the same type but with /// different [freshKeyParams] values do not affect each other. /// /// ```dart /// class LoadUserCart extends ReduxAction with Fresh { /// final String userId; /// LoadUserCart(this.userId); /// /// // The fresh-key parameter here is the `userId`, which means /// // each different `(LoadUserCart, userId)` has its own fresh period. /// Object? freshKeyParams() => userId; /// ... /// ``` /// /// You can also return more than one field by using a tuple: /// /// ```dart /// // Each different `(LoadUserCart, userId, cartId)` has its own fresh period. /// Object? freshKeyParams() => (userId, cartId); /// ``` /// /// ## Configuring how long data stays fresh /// /// The [freshFor] value is given in milliseconds. The default is `1000` /// (1 second). /// /// To keep the data fresh for 5 seconds: /// /// /// ```dart /// class LoadInformation extends ReduxAction with Fresh { /// int freshFor = 500; // Here! /// ... /// } /// ``` /// /// /// ## Forcing the action to run /// /// Sometimes you want to run the action even if the data is still fresh. For /// that, you can override [ignoreFresh]. When [ignoreFresh] is `true`, the /// action always runs and also starts a new fresh period for its key. /// /// A common pattern is to add a `force` flag: /// /// ```dart /// class LoadInformation extends ReduxAction with Fresh { /// final bool force; /// LoadInformation({this.force = false}); /// /// bool get ignoreFresh => force; // Here! /// ... /// } /// ``` /// /// With this setup: /// * `LoadInformation()` runs only when its key is stale. /// * `LoadInformation(force: true)` always runs and also refreshes the key. /// /// /// ## When the action fails /// /// If an action that uses [Fresh] throws an error, the mixin tries to behave /// as if that failing run did not make the key stay fresh for longer. /// /// In practice: /// * If there was no fresh entry for that key before the action started, /// the key is cleared. You can dispatch the action again right away. /// * If there was already a fresh time stored for that key, that time is kept. /// * If another action using the same key finished after this one started and /// changed the fresh time, that newer fresh time is kept as is. /// /// This means: /// * Errors never extend the fresh time by themselves. /// * A failure from an older action does not cancel a newer successful action /// that used the same fresh-key. /// /// You can also control this by hand: /// /// * Call [removeKey] from your action (for example inside [reduce] or /// [before]) to remove the key used by that action, so the next dispatch /// for that key can run immediately. /// * Call [removeAllKeys] from your action to clear all keys and let all /// actions run again as if nothing was fresh. This is probably useful /// during logout or similar scenarios. /// /// Expired keys are cleaned automatically over time, so you usually do not /// need to worry about old entries. /// /// /// ## Using [computeFreshKey] to share keys across actions /// /// If you want different action types to share the same key, override /// [computeFreshKey]. This is useful when several actions read or write the /// same logical resource and should respect the same fresh period. /// /// For example, two actions that work on the same user data: /// /// ```dart /// class LoadUserProfile extends ReduxAction with Fresh { /// final String userId; /// LoadUserProfile(this.userId); /// /// Object computeFreshKey() => userId; // key is only userId /// ... /// } /// /// class LoadUserSettings extends ReduxAction with Fresh { /// final String userId; /// LoadUserSettings(this.userId); /// /// Object computeFreshKey() => userId; // same key as above /// ... /// } /// ``` /// /// Here: /// * `LoadUserProfile('123')` and `LoadUserSettings('123')` share one fresh /// period, because they use the same key. /// * Any object can be a key, for example an enum or a constant string. /// /// /// Notes: /// - It should not be combined with other mixins or classes that override [abortDispatch] or [after]. /// - It should not be combined with [Throttle], [NonReentrant] or [UnlimitedRetryCheckInternet]. /// mixin Fresh on ReduxAction { // int get freshFor => 1000; // Milliseconds bool get ignoreFresh => false; /// By default the fresh key is based on the action [runtimeType]. /// For example, all actions of type `LoadText` share the same /// freshness: /// /// ```dart /// // This action runs. /// dispatch(LoadText(url: 'https://example.com')); /// /// // This does NOT run, because the previous LoadText is still fresh. /// dispatch(LoadText(url: 'https://another-url.com')); /// ```dart /// /// You can override [freshKeyParams] so that actions of the SAME TYPE /// but with different parameters do not affect each other's freshness. /// In this example, the `url` field becomes part of the fresh-key: /// /// ```dart /// class LoadText extends ReduxAction with Fresh { /// final String url; /// LoadText(this.url); /// /// // The fresh-key includes the url. /// Object? freshKeyParams() => url; /// ... /// } /// ``` /// /// Now, dispatching two `LoadText` actions with different `url` values /// allows both of them to run, because each one uses a different fresh-key: /// /// ```dart /// // This action runs. /// dispatch(LoadText(url: 'https://example.com')); /// /// // This also runs, because the url is different, so it has a different fresh-key. /// dispatch(LoadText(url: 'https://another-url.com')); /// ``` /// /// ## In more detail /// /// The default fresh-key, as returned by [computeFreshKey], combines the /// action [runtimeType] with the value returned by [freshKeyParams]. /// /// Most of the time you override [freshKeyParams] to return one field, /// or a tuple of fields: /// /// ```dart /// // Fresh-key is runtimeType + url /// Object? freshKeyParams() => url; /// /// // Fresh-key is runtimeType + userId + cartId /// Object? freshKeyParams() => (userId, cartId); /// ``` /// /// When [freshKeyParams] returns `null`, the key is just the action type. /// In that case all actions of that type share the same freshness. /// /// See also: /// - [computeFreshKey] if you want full control over how the key is built. /// Object? freshKeyParams() => null; /// In most cases you want to use the default fresh-key computation, which /// combines the action's [runtimeType] with the value returned by /// [freshKeyParams]: /// /// ```dart /// Object? computeFreshKey() => (runtimeType, freshKeyParams()); /// ``` /// /// However, if you want different action types to share the same fresh /// period, you must override [computeFreshKey] and return any key you want. /// Some examples: /// /// ```dart /// // The fresh-key is only the url, without the runtimeType. /// Object? computeFreshKey() => url; /// /// // The fresh-key is a pair of values, without the runtimeType. /// Object? computeFreshKey() => (userId, cartId); /// /// // The fresh-key is a constant string. /// Object? computeFreshKey() => 'myKey'; /// /// // The fresh-key is an enum value. /// Object? computeFreshKey() => MyFreshnessKey.myKey; /// ``` /// /// For example, suppose you have two different actions, and you want /// them to share the same fresh-key: /// /// ```dart /// class LoadUserProfile extends ReduxAction with Fresh { /// final String userId; /// LoadUserProfile(this.userId); /// /// // The key is the userId only, without the runtimeType. /// Object? computeFreshKey() => userId; /// ... /// } /// /// class LoadUserSettings extends ReduxAction with Fresh { /// final String userId; /// LoadUserSettings(this.userId); /// /// // The key is the userId only, without the runtimeType. /// Object? computeFreshKey() => userId; /// ... /// } /// ``` /// /// With this setup, if you dispatch `LoadUserProfile('123')`, /// then `LoadUserSettings('123')` will be aborted if dispatched within the /// fresh period of the first action. /// /// See also: /// - [freshKeyParams] when you want to differentiate fresh-keys by some /// of the fields of the action. /// Object computeFreshKey() => (runtimeType, freshKeyParams()); /// Map that stores the expiry time and a unique token for each key. /// The value is a record of (expiry DateTime, unique token Object). Map get _freshKeyMap => store.internalMixinProps.freshKeyMap; /// Removes the fresh-key used by this action, allowing an action using the /// same fresh-key to be dispatched and run again, right away. /// Calling this method will make the action stale immediately. /// You generally do not need to call this method, but if you do, use /// it only from your action's [reduce] or [before] methods. void removeKey() { _freshKeyMap.remove(_freshKey); _keysRemoved = true; } /// Removes all fresh-key, allowing all actions to be dispatched and /// run again right away. /// Calling this method will make all actions stale immediately. /// You generally do not need to call this method, but if you do, use /// it only from your action's [reduce] or [before] methods. void removeAllKeys() { _freshKeyMap.clear(); _keysRemoved = true; } (DateTime, Object)? _current; Object? _freshKey; bool _keysRemoved = false; Object? _newToken; @override bool abortDispatch() { _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet(); // First, check the super class/mixin wants to abort. // See the comment in [NonReentrant.abortDispatch]. if (super.abortDispatch()) return true; _keysRemoved = false; // good to reset here _freshKey = computeFreshKey(); _current = _freshKeyMap[_freshKey]; final now = DateTime.now().toUtc(); if (ignoreFresh) { final expiry = _expiringKeyFrom(now); final token = Object(); // Unique token for this action invocation. _freshKeyMap[_freshKey] = (expiry, token); _newToken = token; _current = null; // Make it stale if the action fails. return false; } final expiresAt = _current?.$1; if (expiresAt == null || !expiresAt.isAfter(now)) { final expiry = _expiringKeyFrom(now); final token = Object(); // Unique token for this action invocation. _freshKeyMap[_freshKey] = (expiry, token); _newToken = token; return false; } // Still fresh, abort. _newToken = null; return true; } void _cannot_combine_mixins_Fresh_Throttle_NonReentrant_UnlimitedRetryCheckInternet() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } DateTime _expiringKeyFrom(DateTime now) => now.add(Duration(milliseconds: freshFor)); /// Remove keys whose expiry time is in the past or now. void _prune() { final now = DateTime.now().toUtc(); _freshKeyMap.removeWhere((_, value) => !value.$1.isAfter(now)); } @override void after() { if (!_keysRemoved && status.originalError != null && _freshKey != null) { final current = _freshKeyMap[_freshKey]; // Only rollback if the map still contains the entry written by THIS action. // Use identical() on the token to reliably detect ownership, since DateTime // equality can match different actions that happen in the same millisecond. if (current != null && identical(current.$2, _newToken)) { if (_current == null) { // No previous expiry: remove key (stale). _freshKeyMap.remove(_freshKey); } else { // Restore previous expiry with a new token (previous owner is gone). _freshKeyMap[_freshKey] = _current!; } } } _prune(); } } void _incompatible(Object instance) { assert( instance is! T2, 'The ${T1.toString().split('<').first} mixin ' 'cannot be combined with the ${T2.toString().split('<').first} mixin.', ); } /// Mixin [OptimisticSync] is designed for actions where user interactions /// (like toggling a "like" button) should update the UI immediately and /// send the updated value to the server, making sure the server and the UI /// are eventually consistent. /// /// --- /// /// The action is not throttled or debounced in any way, and every dispatch /// applies an optimistic update to the state immediately. This guarantees a /// very good user experience, because there is immediate feedback on every /// interaction. /// /// However, while the first updated value (created by the first time the action /// is dispatched) is immediately sent to the server, any other value changes /// that occur while the first request is in flight will NOT be sent immediately. /// /// Instead, when the first request completes, it checks if the state is still /// the same as the value that was sent. If not, a follow-up request is sent /// with the latest value. This process repeats until the state stabilizes. /// /// Note this guarantees that only **one** request is in flight at a time per /// key, potentially reducing the number of requests sent to the server while /// still coalescing intermediate changes. /// /// Optionally: /// /// * If the server responds with a value, that value is applied to the state. /// This is useful when the server normalizes or modifies values. /// /// * When the state finally stabilizes and the request finishes, a callback /// function is called, allowing you to perform side-effects. /// /// * In special, if the last request fails, the optimistic state remains, but /// in the callback you can then load the current state from the server or /// handle the error as you see first by returning a value that will be /// applied to the state. /// /// In other words, the mixin makes it easy for you to maintain perfect UI /// responsiveness while minimizing server load, and making sure the server and /// the UI eventually agree on the same value. /// /// --- /// /// ## How it works /// /// 1. **Immediate UI feedback**: Every dispatch applies [valueToApply] to the /// state immediately via [applyOptimisticValueToState]. /// /// 2. **Single in-flight request**: Only one request runs at a time per key /// (as defined by [optimisticSyncKeyParams]). The first dispatch acquires a lock /// and calls [sendValueToServer] to send a request to the server. /// /// 3. **OptimisticSync changes**: If the store state changed while a request started /// by [sendValueToServer] was in flight (for example, the user tapped a /// "like" button again while the first request was pending), a follow-up /// request is automatically sent after the current one completes. The change /// is detected by comparing [getValueFromState] with the sent value returned /// by [valueToApply]. /// /// 4. **No unnecessary requests**: If, while the request is in-flight, the /// state changes but then returns to the same value as before (for example, /// the user tapped a "like" button again TWICE while the first request was /// pending), [getValueFromState] matches the sent value and no follow-up /// request is needed. /// /// 5. **Server response handling**: If [sendValueToServer] returns a non-null /// value, it is applied to the state via [applyServerResponseToState] when /// the state stabilizes. This is optional but useful. /// /// 6. **Completion callback**: When the synchronization cycle for this key /// finishes, [onFinish] is called. On success, it runs after the state is /// stable (no follow-up needed) and the lock has been released. On failure, /// it runs right after the request fails and the lock is released, and then /// the action rethrows the error. /// /// ``` /// State: liked = false (server confirmed) /// /// User taps LIKE: /// → State: liked = true (optimistic) /// → Lock acquired, Request 1 sends: setLiked(true) /// /// User taps UNLIKE (Request 1 still in flight): /// → State: liked = false (optimistic) /// → No request sent (locked) /// /// User taps LIKE (Request 1 still in flight): /// → State: liked = true (optimistic) /// → No request sent (locked) /// /// Request 1 completes: /// → Sent value was `true`, current state is `true` /// → They match, no follow-up needed, lock released /// ``` /// /// If the state had been `false` when Request 1 completed, a follow-up /// Request 2 would automatically be sent with `false`. /// /// ## Usage /// /// ```dart /// class ToggleLike extends AppAction with OptimisticSync { /// final String itemId; /// ToggleLike(this.itemId); /// /// // Different items can have concurrent requests /// @override /// Object? optimisticSyncKeyParams() => itemId; /// /// // The new value to apply (toggle current state) /// @override /// bool valueToApply() => !state.items[itemId].liked; /// /// // Apply the optimistic value to the state /// @override /// AppState applyOptimisticValueToState(bool isLiked) => /// state.copyWith(items: state.items.setLiked(itemId, isLiked)); /// /// // Apply the server response to the state (can be different from optimistic) /// @override /// AppState? applyServerResponseToState(Object? serverResponse) => /// state.copyWith(items: state.items.setLiked(itemId, serverResponse as bool)); /// /// // Read the current value from state (used to detect if follow-up needed) /// @override /// Object? getValueFromState(AppState state) => state.items[itemId].liked; /// /// // Send the value to the server, optionally return server-confirmed value /// @override /// Future sendValueToServer(Object? optimisticValue) async { /// final response = await api.setLiked(itemId, optimisticValue); /// return response.liked; // Or return null if server doesn't return a value /// } /// /// // Called when state stabilizes (optional). Return state to apply, or null. /// @override /// Future onFinish(Object? error) async { /// if (error != null) { /// // Handle error: reload from server to restore correct state /// final reloaded = await api.getItem(itemId); /// return state.copyWith(items: state.items.update(itemId, reloaded)); /// } /// return null; // Success, no state change needed /// } /// } /// ``` /// /// ## Server response handling /// /// [sendValueToServer] can return a value from the server. If non-null, this value is /// applied to the state **only when the state stabilizes** (no pending changes). /// This is useful when: /// - The server normalizes or modifies values /// - You want to confirm the server accepted the change /// - The server returns the current state after the update /// /// If the server response differs from the current optimistic state when the /// state stabilizes, a follow-up request will be sent automatically. /// /// ## Error handling /// /// On failure, the optimistic state remains and [onFinish] is called with /// the error. /// /// ## Difference from other mixins /// /// - **vs [Debounce]**: Debounce waits for inactivity before sending *any* /// request. OptimisticSync sends the first request immediately and only coalesces /// subsequent changes. /// /// - **vs [NonReentrant]**: NonReentrant aborts subsequent dispatches entirely. /// OptimisticSync applies the optimistic update and queues a follow-up request. /// /// - **vs [OptimisticCommand]**: OptimisticCommand has rollback logic that breaks /// with concurrent dispatches. OptimisticSync is designed for rapid toggling where /// only the final state matters. /// /// ## Rollback support /// /// The mixin exposes two fields to help with rollback logic in [onFinish]. /// /// - [optimisticValue]: The value returned by [valueToApply] for the current /// dispatch. This is set once at the start of reduce() and remains available /// throughout the action lifecycle, including in [onFinish]. /// /// - [lastSentValue]: The most recent value passed to [sendValueToServer]. /// Updated right before each server request. Useful for debugging/logging. /// /// Example rollback guard using [optimisticValue]: /// /// ```dart /// Future onFinish(Object? error) async { /// if (error != null) { /// // Only rollback if the state still reflects our optimistic update. /// // If the user made another change, don't overwrite it. /// if (getValueFromState(state) == optimisticValue) { /// return applyOptimisticValueToState(state, initialValue); /// } /// } /// return null; /// } /// ``` /// /// Another possibility is to use [onFinish] to reload the value from the /// server. Here is an example: /// /// ```dart /// Future onFinish(Object? error) async { /// try { /// final fresh = await api.fetchValue(itemId); /// return applyServerResponseToState(state, fresh); /// } catch (_) { /// return null; // Ignore reload failures and keep the current state. /// } /// } /// ``` /// /// Notes: /// - It can be combined with [CheckInternet] and [AbortWhenNoInternet]. /// - It should not be combined with [NonReentrant], [Throttle], [Debounce], /// [Fresh], [UnlimitedRetryCheckInternet], [UnlimitedRetries], /// [OptimisticCommand], [OptimisticSyncWithPush], or [ServerPush]. /// mixin OptimisticSync on ReduxAction { // /// The optimistic value that was applied to the state for the current /// dispatch. This is set once at the start of [reduce] to the value returned /// by [valueToApply], and remains available in [onFinish] for rollback logic. late final T optimisticValue; /// The most recent value that was passed to [sendValueToServer]. /// This is updated right before each server request (including follow-ups). /// Useful for debugging, logging, or implementing custom guards. /// Reset to `null` at the start of each dispatch. T? lastSentValue; /// Optionally, override [optimisticSyncKeyParams] to differentiate coalescing by /// action parameters. For example, if you have a like button per item, /// return the item ID so that different items can have concurrent requests: /// /// ```dart /// Object? optimisticSyncKeyParams() => itemId; /// ``` /// /// You can also return a record of values: /// /// ```dart /// Object? optimisticSyncKeyParams() => (userId, itemId); /// ``` /// /// See also: [computeOptimisticSyncKey], which uses this method by default to /// build the key. /// Object? optimisticSyncKeyParams() => null; /// By default the coalescing key combines the action [runtimeType] /// with [optimisticSyncKeyParams]. Override this method if you want /// different action types to share the same coalescing key. Object computeOptimisticSyncKey() => (runtimeType, optimisticSyncKeyParams()); /// Override [valueToApply] to return the value that should be applied /// optimistically to the state and then sent to the server. This is called /// synchronously and only once per dispatch, when the reducer starts. /// /// The value to apply can be anything, and is usually constructed from the /// action fields, and/or from the current [state]. Valid examples are: /// /// ```dart /// // Set the like button to "liked". /// bool valueToApply() => true /// /// // Set the like button to "liked" or "not liked", according to /// // the field `isLiked` of the action. /// bool valueToApply() => isLiked; /// /// // Toggles the current state of the like button. /// bool valueToApply() => !state.items[itemId].isLiked; /// ``` /// T valueToApply(); /// Override [applyOptimisticValueToState] to return a new state where the /// given [optimisticValue] is applied to the current [state]. /// /// Note, AsyncRedux calculates [optimisticValue] by previously /// calling [valueToApply]. /// /// ```dart /// AppState applyOptimisticValueToState(state, isLiked) => /// state.copyWith(items: state.items.setLiked(itemId, isLiked)); /// ``` St applyOptimisticValueToState(St state, T optimisticValue); /// Override [applyServerResponseToState] to return a new state, where the /// given [serverResponse] (previously received from the server when running /// [sendValueToServer]) is applied to the current [state]. Example: /// /// ```dart /// AppState? applyServerResponseToState(state, serverResponse) => /// state.copyWith(items: state.items.setLiked(itemId, serverResponse.isLiked)); /// ``` /// /// Note [serverResponse] is never `null` here, because this method is only /// called when [sendValueToServer] returned a non-null value. /// /// If you decide you DO NOT want to apply the server response to the state, /// simply return `null`. /// St? applyServerResponseToState(St state, Object serverResponse); /// Override [getValueFromState] to extract the value from the current [state]. /// This value will be later compared to one returned by [valueToApply] to /// determine if a follow-up request is needed. /// /// Here is the rationale: /// When a request completes, if the value in the state is different from /// the value that was optimistically applied, it means the user changed it /// again while the request was in flight, so a follow-up request is needed /// to sync the latest value with the server. /// /// ```dart /// bool getValueFromState(state) => state.items[itemId].liked; /// ``` T getValueFromState(St state); /// Override [sendValueToServer] to send the given [optimisticValue] to the /// server, and optionally return the server's response. /// /// Note, AsyncRedux calculates [optimisticValue] by previously /// calling [valueToApply]. /// /// If [sendValueToServer] returns a non-null value, that value will be /// applied to the state, but **only when the state stabilizes** (i.e., when /// there are no more pending requests and the lock is about to be released). /// This prevents the server response from overwriting subsequent user /// interactions that occurred while the request was in flight. /// /// The value in the store state may change while the request is in flight. /// For example, if the user presses a like button once, but then /// presses it again before the first request finishes, the value in the /// store state is now different from the optimistic value that was previously /// applied. In this case, [sendValueToServer] will be called again to create /// a follow-up request to sync the updated state with the server. /// /// If [sendValueToServer] returns `null`, the current optimistic state is /// assumed to be correct and valid. /// /// ```dart /// Future sendValueToServer(Object? optimisticValue) async { /// var response = await api.setLiked(itemId, optimisticValue); /// return response?.liked; // Return server-confirmed value, or null. /// } /// ``` Future sendValueToServer(Object? optimisticValue); /// Optionally, override [onFinish] to run any code after the synchronization /// process completes. For example, you might want to reload related data from /// the server, show a confirmation message, or perform cleanup. /// /// Note [onFinish] is called in both success and failure scenarios. /// On success it runs only after the state is stable for this key. /// On failure it runs immediately after the request fails (there is /// no further stabilization or follow-up). /// /// Important: The synchronization lock is released *before* [onFinish] runs. /// This means new dispatches for the same key may start a new request while /// [onFinish] is still executing. /// /// The [error] parameter will be `null` on success, or contain the error /// object if the request failed. /// /// If [onFinish] returns a non-null state, it will be applied automatically. /// If it returns `null`, no state change is made. /// /// ```dart /// Future onFinish(Object? error) async { /// if (error == null) { /// // Success: show confirmation, log analytics, etc. /// return null; /// } else { /// // Failure: /// // - Show a dialog. /// // - Reload data from the server. /// // - Rollback the optimistic update. /// } /// } /// ``` /// /// To show an error dialog in [onFinish]: /// /// ```dart /// dispatch(UserExceptionAction('The server request failed', reason: 'Info reloaded.'); /// ``` /// /// To reload data from the server in [onFinish]: /// /// ```dart /// return state.copy(info: await api.loadInfo()); /// ``` /// /// To rollback the optimistic update in [onFinish]: /// /// ```dart /// return state.copy(isLiked: getValueFromState(initialState)); /// ``` /// /// You can combine the above strategies as needed: /// /// ```dart /// Future onFinish(Object? error) async { /// if (error == null) return null; /// /// // 1. Show an error message to the user. /// dispatch(UserExceptionAction('The server request failed', reason: 'Info reloaded.')); /// /// // 2. Immediately rollback to the initial state before the action. /// dispatchState(state.copy(info: await api.loadInfo()); /// /// // 3. Then, to be sure, reload the value from the database. /// return state.copy(isLiked: getValueFromState(initialState)); /// } /// ``` /// /// Important: /// /// - If `onFinish(error)` throws, the original [error] is lost and the error /// thrown by [onFinish] becomes the action error. You can handle it in /// [wrapError]. /// /// - Same on success: If `onFinish(null)` throws, the whole action fails /// even though the server request succeeded. You can handle it in /// [wrapError]. /// Future onFinish(Object? error) async => null; @override Future reduce() async { _cannot_combine_mixins_OptimisticSync(); _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush(); // Reset per-dispatch tracking fields. lastSentValue = null; // Compute and cache the key for this dispatch. var _currentKey = computeOptimisticSyncKey(); final value = valueToApply(); // Store the optimistic value for this dispatch (available in onFinish). optimisticValue = value; // Always apply optimistic update immediately. dispatchState(applyOptimisticValueToState(state, value)); // If locked, another request is in flight. The optimistic update is // already applied, so just return. When the in-flight request completes, // it will check if a follow-up is needed. if (_optimisticSyncKeySet.contains(_currentKey)) return null; // Acquire lock and send request. _optimisticSyncKeySet.add(_currentKey); await _sendAndFollowUp(_currentKey, value); return null; } /// Set that tracks which keys are currently locked (requests in flight). Set get _optimisticSyncKeySet => store.internalMixinProps.optimisticSyncKeySet; /// Sends the request and handles follow-up requests if the state changed /// (by comparing the value returned by [getValueFromState] with [sentValue]) /// while the request was in flight. /// Future _sendAndFollowUp(Object? key, T sentValue) async { T _sentValue = sentValue; int requestCount = 0; while (true) { requestCount++; try { // Track the value being sent (for debugging/rollback guards). lastSentValue = _sentValue; // Send the value and get the server response (may be null). final Object? serverResponse = await sendValueToServer(_sentValue); // Read the current value from the store. // WARNING: In push mode this may reflect a server push, not local intent. final stateValue = getValueFromState(state); bool needFollowUp = false; // Original value-based behavior (no push compatibility): // If the store value differs from what we sent, send a follow-up with // the current store value. needFollowUp = ifShouldSendAnotherRequest( stateValue: stateValue, sentValue: _sentValue, requestCount: requestCount, ); if (needFollowUp) _sentValue = stateValue; // If we need a follow-up, loop again without applying server response. // The state is not stable yet. if (needFollowUp) continue; // State is stable for this key. Now we may apply the server response, // but only if it is not stale relative to newer pushes. if (serverResponse != null) { final newState = applyServerResponseToState(state, serverResponse); if (newState != null) dispatchState(newState); } // Release lock and finish. _optimisticSyncKeySet.remove(key); await _callOnFinish(null); break; } catch (error) { // Request failed: release lock, run onFinish(error), then rethrow so the // action still fails as before. _optimisticSyncKeySet.remove(key); await _callOnFinish(error); rethrow; } } } /// Calls [onFinish], applying the returned state if non-null. Future _callOnFinish(Object? error) async { final newState = await onFinish(error); if (newState != null) dispatchState(newState); } /// If [ifShouldSendAnotherRequest] returns true, the action will perform one /// more request to try and send the value from the state to the server. /// /// The default behavior of this method is to compare: /// - The [stateValue], which is the value currently in the store state. /// - The [sentValue], which is the value that was sent to the server. /// /// If both are different, it means that the state was changed after /// we sent the request, so we should send another request with the new value. /// /// Optionally, override this method if you need custom equality logic. /// The default implementation uses the `==` operator. /// /// The number of follow-up requests is limited at [maxFollowUpRequests] to /// avoid infinite loops. If that limit is exceeded, a [StateError] is thrown. /// bool ifShouldSendAnotherRequest({ required T stateValue, required T sentValue, required int requestCount, }) { // Safety check to avoid infinite loops. if ((maxFollowUpRequests != -1) && (requestCount > maxFollowUpRequests)) { throw StateError('Too many follow-up requests ' 'in action $runtimeType (> $maxFollowUpRequests).'); } return (stateValue is ImmutableCollection && sentValue is ImmutableCollection) ? !stateValue.same(sentValue) : stateValue != sentValue; } /// Maximum number of follow-up requests to send before throwing an error. /// This is a safety limit to avoid infinite loops. Override if you need a /// different limit. Use `-1` for no limit. int get maxFollowUpRequests => 10000; /// Only [CheckInternet] and [AbortWhenNoInternet] can be combined /// with [OptimisticSync]. void _cannot_combine_mixins_OptimisticSync() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } /// Mixin [OptimisticSyncWithPush] is designed for actions where: /// /// 1. Your app receives server-pushed updates (WebSockets, Server-Sent Events /// (SSE), Firebase) that may modify the same state this action controls. /// It must be resilient to out-of-order delivery, and multiple devices can /// modify the same data. /// /// 2. Non-blocking user interactions (like toggling a "like" button) should /// update the UI immediately and send the updated value to the server, /// making sure the server and the UI are eventually consistent. /// /// 3. You want "last write wins" semantics across devices. In other words, /// with multiple devices, that's how we decide what truth is when two /// devices disagree. /// /// In other words, it allows: /// - Optimistic UI /// - Multi device writes /// - Server push /// - Out of order delivery /// /// **IMPORTANT:** If your app does not receive server-pushed updates, /// use the [OptimisticSync] mixin instead. In any case, please read the /// documentation of [OptimisticSync] first, as this mixin builds upon that /// behavior with additional logic to handle server-pushed updates. /// /// ## How it works /// /// 1. **Immediate UI feedback**: The action is not throttled or debounced in /// any way, and every dispatch applies an optimistic update to the state /// immediately. This guarantees a very good user experience, because there /// is immediate feedback on every interaction. Technically, every dispatch /// applies [valueToApply] to the state immediately via /// [applyOptimisticValueToState]. /// /// 2. **Single in-flight request**: The first time the action is dispatched, /// the updated value is immediately sent to the server. However, any other /// value changes that occur while the first request is in flight will NOT /// be sent, at least immediately. In other words, only **one** request is /// in flight at a time per key (as defined by [computeOptimisticSyncKey] /// and [optimisticSyncKeyParams]), because the first dispatch acquires a /// lock on that key, and other dispatches don't send requests when there is /// a lock. This potentially reduces the number of requests sent to the /// server, while coalescing intermediate changes. /// /// 3. **Follow-up request**: If an action to update the state is dispatched /// while a current request started by [sendValueToServer] is in-flight /// (for example, the user tapped a "like" button again while the first /// request was pending), a follow-up request may be automatically sent after /// the current one completes. The necessity of a follow-up is decided /// automatically when the current request finishes, by internally keeping /// a local-revision associated with the dispatch `key`. This process repeats /// until the state stabilizes. /// /// 4. **Push handling**: If a server push modifies the same state while a /// request is in-flight, when the request completes it checks whether the /// most recent change for this key was recorded as coming from a PUSH. /// If so, no follow-up request is needed, because the push already came /// from the server. This requires server pushes to be applied through /// an action that uses the [ServerPush] mixin, with the same `key` /// as the corresponding [OptimisticSyncWithPush] action. /// /// 5. **Less intermediate requests**: If the state changes many times while the /// request is in-flight, it will coalesce all those changes into a single /// follow-up request. However, since [OptimisticSyncWithPush] uses a /// local-revision to track changes, it can end up sending a follow-up /// request even if the final value is the same as the previously sent value. /// This is necessary since here we assume other devices or users could have /// changed the value on the server in the meantime. Note this is different /// from mixin [OptimisticSync], which assumes only the current user/device /// is changing the value, and then compares the sent value with the current /// state value to decide if a follow-up request is needed. /// /// 6. **Server response handling**: your implementation of [sendValueToServer] /// must call [informServerRevision] with a non-null revision after each /// successful request. If the revision is not informed, the mixin throws /// a [StateError] at runtime. This is necessary to handle out-of-order pushes /// correctly. Also, optionally, if [sendValueToServer] returns a non-null /// value, it is applied to the state via [applyServerResponseToState] when /// the state stabilizes, unless a newer known server revision for this key /// already exists (for example due to a newer push). /// Note: If the request started by [sendValueToServer] fails, then /// [sendValueToServer] should throw an error, and not call [informServerRevision]. /// /// 7. **Completion callback**: When the synchronization cycle for this key /// finishes, [onFinish] is called, allowing you to handle errors or perform /// side-effects, like showing a message or reloading data. On success, it /// runs after the state is stable (no follow-up needed) and the lock has /// been released. On failure, it runs right after the request fails and the /// lock is released, and then the action rethrows the error. /// Note: If the action is dispatched while the key is locked, it still /// applies the optimistic update immediately, but it does not call [onFinish]. /// Only the dispatch that acquired the lock runs the network requests and /// calls [onFinish]. /// /// 8. **Safety limit**: To avoid infinite loops, the mixin enforces a maximum /// number of follow-up requests ([maxFollowUpRequests], default 10000). /// If exceeded, it throws a [StateError]. Override to change the limit /// or use -1 for no limit. /// /// ## Flow example /// /// ``` /// State: liked = false /// /// User taps LIKE: /// → State: liked = true (optimistic). /// → Lock acquired, Request 1 sends: setLiked(true). /// → Local-revision is 1. /// /// User taps UNLIKE (Request 1 still in flight): /// → State: liked = false (optimistic). /// → No request sent (locked). /// → Local-revision is 2. /// /// User taps LIKE (Request 1 still in flight): /// → State: liked = true (optimistic). /// → No request sent (locked). /// → Local-revision is 3. /// /// Request 1 completes: /// → The last state change was NOT done with a PUSH. /// → Compares local-revision of the Request 1 (revision 1) with the current /// local-revision (which is revision 3). /// → They do NOT match, so a follow-up is needed. /// → Request 2 sends: setLiked(true). /// /// Request 2 completes: /// → The last state change was NOT done with a PUSH. /// → Compares local-revision of the Request 2 (revision 3) with the /// current local-revision (which is also revision 3). /// → They match, no follow-up needed. /// → Lock released. /// ``` /// /// ## Flow example with PUSH /// /// ``` /// State: liked = false /// /// User taps LIKE: /// → State: liked = true (optimistic) /// → Lock acquired, Request 1 sends: setLiked(true) /// → Local-revision is 1. /// /// User taps UNLIKE (Request 1 still in flight): /// → State: liked = false (optimistic) /// → No request sent (locked) /// → Local-revision is 2. /// /// A PUSH arrives with liked = false. /// /// Request 1 completes: /// → The last state change was done with a PUSH. /// → So a follow-up is NOT needed. /// → Lock released. /// ``` /// /// ## Code example /// /// ```dart /// class ToggleLikeAction extends ReduxAction /// with OptimisticSyncWithPush { /// /// @override /// Future sendValueToServer( /// Object? optimisticValue, /// int localRevision, // int deviceId, // ) async { /// var response = await api.setLiked(itemId, optimisticValue, localRevision, deviceId); /// if (!response.ok) throw Exception('Server error'); /// informServerRevision(response.serverRev); /// return response.liked; /// } /// } /// ``` /// /// Notes: /// - It can be combined with [CheckInternet] and [AbortWhenNoInternet]. /// - It should not be combined with [NonReentrant], [Retry], [Throttle], /// [Debounce], [Fresh], [UnlimitedRetryCheckInternet], [UnlimitedRetries], /// [OptimisticCommand], [OptimisticSync]. /// - Do not combine with [ServerPush] in the same action. Use [ServerPush] in /// a separate action that only handles server pushes. /// mixin OptimisticSyncWithPush on ReduxAction { // /// Optionally, override [optimisticSyncKeyParams] to differentiate coalescing by /// action parameters. For example, if you have a like button per item, /// return the item ID so that different items can have concurrent requests: /// /// ```dart /// Object? optimisticSyncKeyParams() => itemId; /// ``` /// /// You can also return a record of values: /// /// ```dart /// Object? optimisticSyncKeyParams() => (userId, itemId); /// ``` /// /// See also: [computeOptimisticSyncKey], which uses this method by default to /// build the key. /// Object? optimisticSyncKeyParams() => null; /// By default the coalescing key combines the action [runtimeType] /// with [optimisticSyncKeyParams]. Override this method if you want /// different action types to share the same coalescing key. Object computeOptimisticSyncKey() => (runtimeType, optimisticSyncKeyParams()); /// Override [valueToApply] to return the value that should be applied /// optimistically to the state and then sent to the server. This is called /// synchronously and only once per dispatch, when the reducer starts. /// /// The value to apply can be anything, and is usually constructed from the /// action fields, and/or from the current [state]. Valid examples are: /// /// ```dart /// // Set the like button to "liked". /// bool valueToApply() => true /// /// // Set the like button to "liked" or "not liked", according to /// // the field `isLiked` of the action. /// bool valueToApply() => isLiked; /// /// // Toggles the current state of the like button. /// bool valueToApply() => !state.items[itemId].isLiked; /// ``` /// T valueToApply(); /// Override [applyOptimisticValueToState] to return a new state where the /// given [optimisticValue] is applied to the current [state]. /// /// Note, AsyncRedux calculates [optimisticValue] by previously /// calling [valueToApply]. /// /// ```dart /// AppState applyOptimisticValueToState(state, isLiked) => /// state.copyWith(items: state.items.setLiked(itemId, isLiked)); /// ``` St applyOptimisticValueToState(St state, T optimisticValue); /// Override [applyServerResponseToState] to return a new state, where the /// given [serverResponse] (previously received from the server when running /// [sendValueToServer]) is applied to the current [state]. Example: /// /// ```dart /// AppState? applyServerResponseToState(state, serverResponse) => /// state.copyWith(items: state.items.setLiked(itemId, serverResponse.isLiked)); /// ``` /// /// Note [serverResponse] is never `null` here, because this method is only /// called when [sendValueToServer] returned a non-null value. /// /// If you decide you DO NOT want to apply the server response to the state, /// simply return `null`. /// St? applyServerResponseToState(St state, Object serverResponse); /// Override [getValueFromState] to extract the value from the current [state]. /// If a follow-up request is needed, the value returned by [getValueFromState] /// is the one that will now be sent to the server. /// /// ```dart /// bool getValueFromState(state) => state.items[itemId].liked; /// ``` T getValueFromState(St state); /// The device ID is used to differentiate revisions from different devices. /// The default is to use a random integer generated once per app run, /// but you can override this to return a persistent unique ID per device. static int Function() deviceId = () { _deviceId ??= Random().nextInt(4294967296) + (Random().nextInt(10000) * 10000000000); return _deviceId!; }; static int? _deviceId; /// Override [sendValueToServer] to: /// - Send the given [optimisticValue], the [localRevision], and the [deviceId] /// to the server. /// - Set the current server-revision, by calling [informServerRevision]. /// - Optionally, return the server's response. /// - You must throw an error if the request fails (in this case, do not /// call [informServerRevision]). /// /// Notes: /// - AsyncRedux calculates [optimisticValue] by previously calling [valueToApply]. /// - The server must return the server-revision in the response. /// - Server pushes must provide the 3 pieces of information: server-revision, /// [deviceId], and [localRevision]. See [ServerPush] for details. /// /// If [sendValueToServer] returns a non-null value, that value will be /// applied to the state, but **only when the state stabilizes** (i.e., when /// there are no more pending requests and the lock is about to be released). /// This prevents the server response from overwriting subsequent user /// interactions that occurred while the request was in flight. /// /// The value in the store state may change while the request is in flight, /// both because of user interactions and because of server pushes. /// In case the most recent state change was due to a user interaction, /// (for example, if the user presses a like button once, but then presses /// it again before the first request finishes), then [sendValueToServer] will /// be called again to create a follow-up request to sync the updated state /// with the server. In case the most recent state change was due to a server /// push, no follow-up request is needed. /// /// ```dart /// Future sendValueToServer( /// Object? optimisticValue, /// int localRevision, /// int deviceId) async { /// var response = await api.setLiked(itemId, optimisticValue, localRevision, deviceId); /// if (!response.ok) throw Exception('Server error'); /// informServerRevision(response.serverRev); /// return response.liked; // The mixin decides whether to apply this /// } /// ``` Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ); /// Each dispatch calls [_localRevision] to increment the revision for /// this key (the first call per dispatch increments; subsequent calls in /// the same dispatch return the same value). The local-revision for the key /// is stored in [_optimisticSyncWithPushRevisionMap]. /// /// ## In more detail: /// /// Some state value may change because of: /// /// - An action was dispatched to change the value, in response to a user /// interaction. This is what happens when the user taps a like button, /// for example. These dispatched values are put optimistically /// in the state immediately, and this increments the local-revision. /// /// - A value may have arrived through a server push. These do NOT increment /// the local-revision. /// /// When a request completes, this is how we decide if we need to send a /// follow-up request: /// /// - If the last applied value is from a PUSH, there is no need to send /// a follow-up. /// /// - If the last applied value is NOT from a PUSH, then we have to check /// the local-revision: If the local-revision of the request we sent is /// less than the current local-revision in the state, it means some other /// value was dispatched while the request was in flight, so we need to /// send a follow-up request with the latest value. /// int _localRevision() { final key = _currentKey!; if (_lazyLocalRevision == null) { final current = _optimisticSyncWithPushRevisionMap[key]; // Increment for this dispatch. _lazyLocalRevision = (current?.localRevision ?? 0) + 1; final int fromMap = current?.serverRevision ?? -1; final int fromState = getServerRevisionFromState(key); final int seededServerRev = max(fromMap, fromState); _optimisticSyncWithPushRevisionMap[key] = ( localRevision: _lazyLocalRevision!, serverRevision: seededServerRev, isPush: false, ); } return _lazyLocalRevision!; } int? _lazyLocalRevision; /// Tracks the server revision informed by the server during /// [sendValueToServer], which calls [informServerRevision]. /// This value is reset before each call to [sendValueToServer], so that /// if it's null we know the server-revision was not informed correctly. int? _informedServerRev; /// Cached coalescing key for the current dispatch. /// Computed once and then reused. Object? _currentKey; /// You must override this to return the server revision you saved in the /// state in [ServerPush.applyServerPushToState] for the given [key]. /// Do return `-1` when unknown. int getServerRevisionFromState(Object? key); /// It's mandatory that you call [informServerRevision] from your overridden /// [sendValueToServer], to inform the mixin about the server-revision /// returned in the response. /// /// The server must provide a monotonically increasing revision number, /// (for example, a timestamp, a version number, etc), comparable across /// devices and users, that allows the app to determine the ordering of updates. /// /// The mixin uses this information internally to: /// - Track the latest known server revision (for "last write wins" ordering) /// - Determine whether to apply the server response (stale responses are /// automatically ignored). /// /// **Usage:** Just call this method with the serverRevision from the response. /// The mixin handles all the logic - you don't need to check or compare /// anything yourself. Example: /// /// ```dart /// @override /// Future sendValueToServer( /// Object? optimisticValue, /// int localRevision, /// int deviceId, /// ) async { /// var response = await api.setLiked(itemId, optimisticValue, localRevision, deviceId); /// if (!response.ok) throw Exception('Server error'); /// informServerRevision(response.serverRev); /// return response.liked; /// } /// } /// ``` /// /// **Behavior:** /// /// - Only updates the stored serverRevision if `revision` is greater than /// the newest known serverRevision for this key, considering both: /// (1) the mixin's internal map entry (if any) and /// (2) `getServerRevisionFromState(key)` (if you persisted one in state). /// This prevents regression from stale or out-of-order updates. /// /// - The mixin will only apply the returned server response if this revision /// is not older than the newest known revision. /// /// See also: [informServerRevisionAsDateTime]. /// void informServerRevision(int revision) { _informedServerRev = revision; final key = _currentKey!; final entry = _optimisticSyncWithPushRevisionMap[key]; final int fromMap = entry?.serverRevision ?? -1; final int fromState = getServerRevisionFromState(key); // should return -1 if unknown final int currentServerRev = max(fromMap, fromState); // Only move forward, but keep local intent info. if (revision > currentServerRev) { _optimisticSyncWithPushRevisionMap[key] = ( localRevision: entry?.localRevision ?? 0, serverRevision: revision, isPush: false, ); } } /// Convenience method to inform the server revision from a DateTime. /// Uses `millisecondsSinceEpoch` as the revision number. /// /// See also: [informServerRevision]. /// void informServerRevisionAsDateTime(DateTime revision) { informServerRevision(revision.millisecondsSinceEpoch); } /// Optionally, override [onFinish] to run any code after the synchronization /// process completes. For example, you might want to reload related data from /// the server, show a confirmation message, or perform cleanup. /// /// Note [onFinish] is called in both success and failure scenarios, but only /// after the state stabilizes for this key (that is, after the last request /// finishes and no follow-up request is needed). /// /// Important: The synchronization lock is released *before* [onFinish] runs. /// This means new dispatches for the same key may start a new request while /// [onFinish] is still executing. /// /// The [error] parameter will be `null` on success, or contain the error /// object if the request failed. /// /// If [onFinish] returns a non-null state, it is applied. On success it /// becomes the action's final reduced state. On failure it is dispatched and /// then the original error is rethrown. If it returns `null`, no extra state /// change is made. /// /// ```dart /// Future onFinish(Object? error) async { /// if (error == null) { /// // Success: show confirmation, log analytics, etc. /// return null; /// } else { /// // Failure: reload data from the server. /// var reloadedInfo = await api.loadInfo(); /// return state.copy(info: reloadedInfo); /// } /// } /// ``` /// /// Important: /// /// - If `onFinish(error)` throws, the original [error] is lost and the error /// thrown by [onFinish] becomes the action error. You can handle it in /// [wrapError]. /// /// - Same on success: If `onFinish(null)` throws, the whole action fails /// even though the server request succeeded. You can handle it in /// [wrapError]. /// Future onFinish(Object? error) async => null; @override Future reduce() async { _cannot_combine_mixins_OptimisticSyncWithPush(); _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush(); // Compute and cache the key for this dispatch. _currentKey = computeOptimisticSyncKey(); var localRevision = _localRevision(); T value = valueToApply(); // Always apply optimistic update immediately. dispatchState(applyOptimisticValueToState(state, value)); // If locked, another request is in flight. The optimistic update is // already applied, so just return. When the in-flight request completes, // it will check if a follow-up is needed. if (_optimisticSyncKeySet.contains(_currentKey)) return null; // Acquire lock. _optimisticSyncKeySet.add(_currentKey); int requestCount = 0; while (true) { // // Safety check to avoid infinite loops. requestCount++; if ((maxFollowUpRequests != -1) && (requestCount > maxFollowUpRequests)) { throw StateError('Too many follow-up requests ' 'in action $runtimeType (> $maxFollowUpRequests).'); } // Reset before each request so we can detect whether the user called // `informServerRevision()` while executing `sendValueToServer`. _informedServerRev = null; try { // Send the value and get the server response (may be null). final Object? serverResponse = await sendValueToServer( value, localRevision, deviceId(), ); // Validate that the developer called informServerRevision(). if (_informedServerRev == null) { throw StateError( 'The OptimisticSyncWithPush mixin requires calling ' 'informServerRevision() inside sendValueToServer(). ' 'If you don\'t need server-push handling, use OptimisticSync instead.', ); } // Revision-based follow-up decision: // If localRevision advanced since this request started, the user changed // intent while the request was in flight, so we may need a follow-up. final entry = _getEntry(_currentKey); final int currentLocalRev = entry.localRevision; final int currentServerRev = entry.serverRevision; final bool isPush = entry.isPush; // If the current value was created by the user locally (it's not // from push), and localRevision advanced, we need a follow-up. if (!isPush && (currentLocalRev > localRevision)) { _optimisticSyncWithPushRevisionMap[_currentKey] = ( localRevision: currentLocalRev, serverRevision: currentServerRev, isPush: false, ); // Read the current value from the store. // Will loop one more time, to do the follow-up request. value = getValueFromState(state); localRevision = currentLocalRev; } // // If the state is stable for this key, we may apply the server response, // but only if it is not stale relative to newer pushes. else { // State is stable for this key. Now we may apply the server response, // but only if it is not stale relative to newer pushes. if (serverResponse != null) { // Only apply if the informed server revision still matches the latest // known server revision for this key (i.e., no newer push arrived). final bool shouldApply = _informedServerRev! >= currentServerRev; if (shouldApply) { _optimisticSyncWithPushRevisionMap[_currentKey] = ( localRevision: currentLocalRev, serverRevision: _informedServerRev!, isPush: false, ); final newState = applyServerResponseToState(state, serverResponse); if (newState != null) dispatchState(newState); } } // Release lock and finish. _optimisticSyncKeySet.remove(_currentKey); final newState = await onFinish(null); // Break the loop. if (newState != null) return newState; break; } } // catch (error) { // Request failed: release lock, run onFinish(error), // then rethrow so the action still fails as before. _optimisticSyncKeySet.remove(_currentKey); final newState = await onFinish(error); if (newState != null) dispatchState(newState); rethrow; } } return null; } /// Set that tracks which keys are currently locked (requests in flight). Set get _optimisticSyncKeySet => store.internalMixinProps.optimisticSyncKeySet; /// Map used by the [OptimisticSyncWithPush] and [ServerPush] mixins. Map get _optimisticSyncWithPushRevisionMap => store.internalMixinProps.optimisticSyncWithPushRevisionMap; OptimisticSyncWithPushRevisionEntry _getEntry(Object? key) => _optimisticSyncWithPushRevisionMap[key] ?? ( localRevision: 0, serverRevision: getServerRevisionFromState(key), isPush: false, ); /// Maximum number of follow-up requests to send before throwing an error. /// This is a safety limit to avoid infinite loops. Override if you need a /// different limit. Use `-1` for no limit. int get maxFollowUpRequests => 10000; /// Only [CheckInternet] and [AbortWhenNoInternet] can be combined /// with [OptimisticSyncWithPush]. void _cannot_combine_mixins_OptimisticSyncWithPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } typedef PushMetadata = ({ int serverRevision, int localRevision, int deviceId, }); /// Mixin [ServerPush] should be used by actions that put, in the store state, /// values that were received by server-push, via WebSockets, Server-Sent /// Events (SSE), Firebase, etc. /// /// It works together with [OptimisticSyncWithPush] to ensure that out-of-order /// pushes do not corrupt the state, and that local optimistic updates are not /// overwritten by stale pushes. /// mixin ServerPush on ReduxAction { /// You must override this to return the type of the action that uses the /// corresponding [OptimisticSyncWithPush] that owns this value (so both /// compute the same stable-sync key). Type associatedAction(); /// Same meaning as in [OptimisticSyncWithPush]: /// the params that differentiate keys. Object? optimisticSyncKeyParams() => null; /// Must match the [OptimisticSyncWithPush] action key computation. /// Default: (associatedActionType, optimisticSyncKeyParams) Object computeOptimisticSyncKey() => (associatedAction(), optimisticSyncKeyParams()); /// You must override this to provide the [PushMetadata] that came with the /// push, including: /// /// - The server-revision number. /// - The local-revision number. /// - The device-ID. /// /// For example: /// /// ```dart /// class PushLikeUpdate extends AppAction with ServerPush { /// final bool liked; /// final PushMetadata metadata; /// PushLikeUpdate({required this.liked, required this.metadata}); /// /// Type associatedAction() => ToggleLikeAction; /// /// PushMetadata pushMetadata() => metadata; /// /// AppState? applyServerPushToState(AppState state, Object? key, int serverRev) /// => state.copy(liked: liked, revision: (key, serverRev)); /// } /// ``` PushMetadata pushMetadata(); /// You must override this to: /// - Apply the pushed data to [state]. /// - Save the [serverRevision] for the current [key] to the [state]. /// /// Return `null` to ignore the push. /// St? applyServerPushToState(St state, Object? key, int serverRevision); /// You must override this to return the server revision you saved in the /// state in [ServerPush.applyServerPushToState] for the given [key]. /// Do return `-1` when unknown. int getServerRevisionFromState(Object? key); @override St? reduce() { _cannot_combine_mixins_ServerPush(); _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush(); final key = computeOptimisticSyncKey(); var ( :serverRevision, :localRevision, :deviceId, ) = pushMetadata(); final current = _optimisticSyncWithPushRevisionMap[key]; final int serverRevision_FromMap = current?.serverRevision ?? -1; final int serverRevision_FromState = getServerRevisionFromState(key); // Determine the current known server revision for this key. // This is the max of what we have in the map versus what is in the state. final currentServerRev = max(serverRevision_FromMap, serverRevision_FromState); // Seed the map from persisted state, if needed. // This is important even when we ignore the push as stale. if ((serverRevision_FromMap == -1) && (serverRevision_FromState >= 0)) { _optimisticSyncWithPushRevisionMap[key] = ( localRevision: 0, serverRevision: serverRevision_FromState, isPush: true, ); } // Ignore stale/out-of-order pushes. if (serverRevision <= currentServerRev) { return null; } final entry = _optimisticSyncWithPushRevisionMap[key]; final int currentLocalRev = entry?.localRevision ?? 0; final bool isSelf = (deviceId == OptimisticSyncWithPush.deviceId()); // Self-echo of an older request: treat as ACK only. // Do NOT apply and do NOT mark isPush=true (otherwise it cancels follow-ups). if (isSelf && (localRevision < currentLocalRev)) { _optimisticSyncWithPushRevisionMap[key] = ( localRevision: currentLocalRev, serverRevision: serverRevision, isPush: false, ); return null; } // Safe to apply (external push, or self echo that matches latest intent). final newState = applyServerPushToState(state, key, serverRevision); // Always record newest known server revision, even if user ignores the push (newState == null). final int storedLocalRev = isSelf ? max(currentLocalRev, localRevision) : currentLocalRev; _optimisticSyncWithPushRevisionMap[key] = ( localRevision: storedLocalRev, serverRevision: serverRevision, isPush: true, ); return newState; } Map get _optimisticSyncWithPushRevisionMap => store.internalMixinProps.optimisticSyncWithPushRevisionMap; void _cannot_combine_mixins_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } void _cannot_combine_mixins_UnlimitedRetryCheckInternet_OptimisticCommand_OptimisticSync_OptimisticSyncWithPush_ServerPush() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } enum Poll { /// Start polling. /// If polling is already active, does nothing. /// Otherwise, runs the action immediately and starts periodic polling. start, /// Stop polling (cancels the timer and does not run the action). stop, /// Run the action immediately and restart polling from now. /// If polling is not active, behaves like [Poll.start]. runNowAndRestart, /// Run the action once immediately. /// Does not start, stop, cancel, or restart polling. once, } /// Mixin [Polling] can be used to periodically dispatch an action at a fixed /// interval. Just add `with Polling` to your action. For example: /// /// ```dart /// class PollPrices extends AppAction with Polling { /// @override final Poll poll; /// PollPrices([this.poll = Poll.once]); /// /// @override /// ReduxAction createPollingAction() => PollPrices(); /// /// @override /// Future reduce() async { /// final prices = await api.getPrices(); /// return state.copy(prices: prices); /// } /// } /// ``` /// /// This is useful when you need to keep data fresh by fetching it from a server /// at regular intervals, such as refreshing prices, checking for new messages, /// or monitoring wallet balances. /// /// The [pollInterval] is the delay between polling ticks. The default is 10 /// seconds. You can override it: /// /// ```dart /// @override /// Duration get pollInterval => const Duration(minutes: 5); /// ``` /// /// To start polling, dispatch the action with [Poll.start]. /// To stop, dispatch with [Poll.stop]: /// /// ```dart /// // Start polling (also runs reduce immediately): /// dispatch(PollPrices(Poll.start)); /// /// // Stop polling: /// dispatch(PollPrices(Poll.stop)); /// ``` /// /// You can display loading states and errors in your widgets by tracking the /// action type that does the work: /// /// ```dart /// if (context.isWaiting(PollPrices)) CircularProgressIndicator(); /// if (context.isFailed(PollPrices)) Text('Failed to load prices'); /// ``` /// /// If you use two separate action types (see Option 2 below), track the worker /// action instead of the polling controller. /// /// There are two ways to use this mixin: /// /// ## Option 1: Single action for everything /// /// Use one action class that both controls polling and does the work. /// The [createPollingAction] returns the same action type with [Poll.once] /// (or with no poll field at all, since [Poll.once] is the default), /// so timer ticks run the action without restarting the timer: /// /// ```dart /// class LoadBalanceAction extends AppAction with Polling { /// final WalletAddress address; /// @override final Poll poll; /// /// LoadBalanceAction(this.address, {this.poll = Poll.once}); /// /// @override /// Duration get pollInterval => const Duration(minutes: 5); /// /// @override /// ReduxAction createPollingAction() => LoadBalanceAction(address); /// /// @override /// Future reduce() async { /// final balance = await api.getBalance(address); /// return state.copy(balance: balance); /// } /// } /// /// // Run immediately without affecting the timer /// dispatch(LoadBalanceAction(address)); /// /// // Start polling /// dispatch(LoadBalanceAction(address, poll: Poll.start)); /// /// // Stop polling /// dispatch(LoadBalanceAction(address, poll: Poll.stop)); /// ``` /// /// ## Option 2: Separate action types /// /// Use one action to control polling, and a different action to do the work. /// This is useful when you want `isWaiting` and `isFailed` to track a /// different type than the polling controller: /// /// ```dart /// class PollBalance extends AppAction with Polling { /// final WalletAddress address; /// @override final Poll poll; /// /// PollBalance(this.address, {this.poll = Poll.start}); /// /// @override /// Duration get pollInterval => const Duration(minutes: 5); /// /// @override /// ReduxAction createPollingAction() => LoadBalanceAction(address); /// /// @override /// Future reduce() async { /// await dispatchAndWait(LoadBalanceAction(address)); /// return null; /// } /// /// class LoadBalanceAction extends AppAction { /// final WalletAddress address; /// LoadBalanceAction(this.address); /// /// @override /// Future reduce() async { /// final balance = await api.getBalance(address); /// return state.copy(balance: balance); /// } /// } /// /// // Start polling: /// dispatch(PollBalance(address, poll: Poll.start)); /// /// // Check loading state of the worker action: /// isWaiting(LoadBalanceAction); /// /// // Stop polling: /// dispatch(PollBalance(address, poll: Poll.stop)); /// ``` /// /// ## Polling keys /// /// By default, each action type gets its own independent polling timer, /// keyed by its [runtimeType]. This means all instances of the same action /// type share one timer. /// /// ### Using [pollingKeyParams] to separate instances /// /// If you need separate polling timers per id, address, or some other field, /// override [pollingKeyParams]. Actions of the same type but with different /// [pollingKeyParams] values get independent timers. /// /// ```dart /// class PollBalance extends AppAction with Polling { /// final WalletAddress address; /// @override final Poll poll; /// /// PollBalance(this.address, {this.poll = Poll.once}); /// /// // Each address gets its own independent polling timer. /// @override /// Object? pollingKeyParams() => address; /// /// @override /// ReduxAction createPollingAction() => /// LoadBalanceAction(address); /// /// @override /// Future reduce() async { /// await dispatchAndWait(LoadBalanceAction(address)); /// return null; /// } /// /// // These start two independent polling timers: /// dispatch(PollBalance(address1, poll: Poll.start)); /// dispatch(PollBalance(address2, poll: Poll.start)); /// /// // Stop only address1: /// dispatch(PollBalance(address1, poll: Poll.stop)); /// ``` /// /// You can also return more than one field by using a tuple: /// /// ```dart /// // Each (userId, walletId) pair gets its own timer. /// Object? pollingKeyParams() => (userId, walletId); /// ``` /// /// ### Using [computePollingKey] to share timers across action types /// /// If you want different action types to share the same polling timer, /// override [computePollingKey] and return any key you want: /// /// ```dart /// class PollPrices extends AppAction with Polling { /// Object computePollingKey() => 'market-data'; /// ... /// } /// /// class PollVolumes extends AppAction with Polling { /// Object computePollingKey() => 'market-data'; // same key /// ... /// } /// ``` /// /// With this setup, starting `PollPrices` and then `PollVolumes` means /// `PollVolumes` is a no-op (the key is already active). Stopping either /// one cancels the shared timer. /// /// ## Poll values /// /// - [Poll.start]: Starts polling and runs [reduce] immediately. /// If polling is already active for this key, does nothing. /// /// - [Poll.stop]: Cancels the polling for this key and skips [reduce]. /// /// - [Poll.runNowAndRestart]: Runs [reduce] immediately and restarts the polling timer /// from that moment. If polling is not active, behaves like [Poll.start]. /// /// - [Poll.once]: Runs [reduce] immediately, without affecting the polling /// (it does not start or stop the polling). /// /// Instead of using a periodic timer, each run schedules the next one, /// so the polling interval is measured from the end of each run. /// /// Notes: /// - This mixin can be combined with [CheckInternet], [AbortWhenNoInternet], /// [NonReentrant], [Throttle], and [Fresh]. /// - It should not be combined with other mixins or classes that override [wrapReduce]. /// - It should not be combined with [Retry], [UnlimitedRetries], [Debounce], /// [UnlimitedRetryCheckInternet], [OptimisticCommand], [OptimisticSync], /// [OptimisticSyncWithPush], or [ServerPush]. /// /// See also: /// * [Throttle] - If you want to limit how often an action runs, but don't need periodic repetition. /// * [Debounce] - If you want to wait for a pause in activity before running the action. /// * [NonReentrant] - If you want to prevent overlapping executions of the same action. /// mixin Polling on ReduxAction { Poll get poll; Duration get pollInterval => const Duration(seconds: 10); /// Must return a new action instance that the timer will dispatch on each /// tick. This can be the same action type with [Poll.once], or a completely /// different action type (see class docs for both patterns). ReduxAction createPollingAction(); /// By default, the polling key is based on the action [runtimeType]. /// All instances of the same action type share one polling timer. /// /// Override this to give each instance its own timer based on some field: /// /// ```dart /// // Each address gets its own polling timer. /// Object? pollingKeyParams() => address; /// /// // Each (userId, walletId) pair gets its own timer. /// Object? pollingKeyParams() => (userId, walletId); /// ``` /// /// When [pollingKeyParams] returns `null` (the default), the key is /// just the action type. Object? pollingKeyParams() => null; /// Returns the key used to identify this action's polling timer. /// /// The default combines [runtimeType] with [pollingKeyParams]: /// ```dart /// Object computePollingKey() => (runtimeType, pollingKeyParams()); /// ``` /// /// Override this for full control, for example to share a timer /// across different action types: /// /// ```dart /// Object computePollingKey() => 'shared-market-data'; /// ``` Object computePollingKey() => (runtimeType, pollingKeyParams()); Map get _pollingMap => store.internalMixinProps.pollingMap; @override Future wrapReduce(Reducer reduce) async { _cannot_combine_mixins_Polling(); final key = computePollingKey(); switch (poll) { case Poll.start: // If polling is already active, don't do anything. if (_pollingMap.containsKey(key)) return null; _scheduleNext(key); return reduce(); case Poll.stop: _pollingMap.remove(key)?.cancel(); return null; case Poll.runNowAndRestart: _pollingMap.remove(key)?.cancel(); _scheduleNext(key); return reduce(); case Poll.once: return reduce(); } } /// Schedules a one-shot timer that dispatches [createPollingAction] and /// then schedules the next tick. The interval is measured from the moment /// the previous tick completes. void _scheduleNext(Object key) { _pollingMap[key] = Timer(pollInterval, () { if (_pollingMap.containsKey(key)) { dispatch(createPollingAction()); _scheduleNext(key); } }); } void _cannot_combine_mixins_Polling() { _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); _incompatible(this); } } ================================================ FILE: lib/src/action_observer.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:io'; import 'package:async_redux/async_redux.dart'; abstract class ActionObserver { /// If `ini==true` this is right before the action is dispatched. /// If `ini==false` this is right after the action finishes. void observe( ReduxAction action, int dispatchCount, { required bool ini, }); } /// This action-observer will print all actions to the console like so: /// /// ``` /// I/flutter (15304): | Action MyAction /// ``` /// /// This helps with development, so you probably don't want to use it in /// release mode: /// /// ``` /// store = Store( /// ... /// actionObservers: kReleaseMode ? null : [ConsoleActionObserver()], /// ); /// ``` /// /// If you implement the action's [toString], you can display more information. /// For example, suppose a LoginAction which has a username field: /// /// ``` /// class LoginAction extends ReduxAction { /// final String username; /// ... /// String toString() => super.toString() + '(username)'; /// } /// ``` /// /// The above code will print something like this: /// /// ``` /// I/flutter (15304): | Action LoginAction(user32) /// ``` /// class ConsoleActionObserver extends ActionObserver { /// If [useAnsiColors] is `true`, the output will use ANSI escape codes for /// colored output. Defaults to `false`, because not all consoles support /// ANSI colors. final bool? useAnsiColors; ConsoleActionObserver({this.useAnsiColors}); @override void observe(ReduxAction action, int dispatchCount, {required bool ini}) { if (ini) { bool _useAnsiColors; // If useAnsiColors is explicitly true, use true. if (useAnsiColors == true) _useAnsiColors = true; // // If useAnsiColors is explicitly false, use false. else if (useAnsiColors == false) _useAnsiColors = false; // // If useAnsiColors is `null`, use ANSI colors on Windows only. // This is because the IntelliJ console on Mac/Linux doesn't support ANSI // colors. Note, ideally we should check if the console itself supports // ANSI colors, but an Android emulator running on Windows doesn't know // where it's running, so we just assume Windows when the target is Windows. else _useAnsiColors = Platform.isWindows; print(_useAnsiColors ? '${color(action)}|$italic $action$reset' : '| $action'); } } /// Callback that chooses the color to print in the console. static String Function(ReduxAction action) color = // (ReduxAction action) => // (action is WaitAction || action is NavigateAction) // ? green : yellow; // See ANSI Colors here: https://pub.dev/packages/ansicolor static const white = "\x1B[38;5;255m"; static const reversed = "\u001b[7m"; static const red = "\x1B[38;5;9m"; static const blue = "\x1B[38;5;45m"; static const yellow = "\x1B[38;5;226m"; static const green = "\x1B[38;5;118m"; static const grey = "\x1B[38;5;246m"; static const dark = "\x1B[38;5;238m"; static const bold = "\u001b[1m"; static const italic = "\u001b[3m"; static const boldItalic = bold + italic; static const boldItalicOff = boldOff + italicOff; static const boldOff = "\u001b[22m"; static const italicOff = "\u001b[23m"; static const reset = "\u001b[0m"; } ================================================ FILE: lib/src/advanced_user_exception.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/foundation.dart'; import "package:meta/meta.dart"; /// Extends the [UserException] to add more features. /// /// The [AdvancedUserException] is not supposed to be instantiated directly. Instead, use /// the [addCallbacks], [addCause] and [addProps] extension methods in the [UserException]: /// /// ```dart /// UserException(message, code: code, reason: reason) /// .addCallbacks(onOk: onOk, onCancel: onCancel) /// .addCause(cause) /// .addProps(props); /// ``` /// /// Example: /// /// ```dart /// throw UserException('Invalid number', reason: 'Must be less than 42') /// .addCallbacks(onOk: () => print('OK'), onCancel: () => print('CANCEL')) /// .addCause(FormatException('Invalid input')) /// .addProps({'number': 42})); /// ``` /// /// When the exception is shown to the user in the [UserExceptionDialog], if /// callbacks [onOk] and [onCancel] are defined, the dialog will have OK and CANCEL buttons, /// and the callbacks will be called when the user taps them. /// /// The [hardCause] is some error which caused the [UserException]. /// /// The [props] are any key-value pair properties you'd like to add to the exception. /// @immutable class AdvancedUserException extends UserException { // /// Callback to be called after the user views the error, and taps OK in the dialog. final VoidCallback? onOk; /// Callback to be called after the user views the error, and taps CANCEL in the dialog. final VoidCallback? onCancel; /// The hard cause is some error which caused the [UserException], but that is not /// a [UserException] itself. For example: `int.parse('a')` throws a `FormatException`. /// Then: `throw UserException('Invalid number').addCause(FormatException('Invalid input'))`. /// will have the `FormatException` as the hard cause. Note: If a [UserException] is /// passed as the hard cause, it will be added with [addCause], and will not become the /// hard cause. In other words, a [UserException] will never be a hard cause. final Object? hardCause; /// The properties added to the exception, if any. /// They are an immutable-map of type [IMap], of key-value pairs. /// To read the properties, use the `[]` operator, like this: /// ```dart /// var value = exception.props['key']; /// ``` /// If the key does not exist, it will return `null`. /// final IMap props; /// Instead of using this constructor directly, prefer doing: /// /// ```dart /// throw UserException('Invalid number', reason: 'Must be less than 42') /// .addCallbacks(onOk: () => print('OK'), onCancel: () => print('CANCEL')) /// .addCause(FormatException('Invalid input')) /// .addProps({'number': 42})); /// ``` /// /// This constructor is public only so that you can subclass [AdvancedUserException]. /// const AdvancedUserException( super.message, { required super.reason, required super.code, required super.errorText, required super.ifOpenDialog, required this.onOk, required this.onCancel, required this.hardCause, this.props = const IMapConst({}), }); @override bool operator ==(Object other) => identical(this, other) || super == other && other is AdvancedUserException && runtimeType == other.runtimeType && onOk == other.onOk && onCancel == other.onCancel && hardCause == other.hardCause && props == other.props; @override int get hashCode => super.hashCode ^ onOk.hashCode ^ onCancel.hashCode ^ hardCause.hashCode ^ props.hashCode; /// Returns a new [UserException], copied from the current one, but adding the given [reason]. /// Note the added [reason] won't replace the original reason, but will be added to it. @useResult @mustBeOverridden @override UserException addReason(String? reason) { UserException exception = super.addReason(reason); return AdvancedUserException( exception.message, reason: exception.reason, code: exception.code, onOk: onOk, onCancel: onCancel, hardCause: hardCause, props: props, errorText: errorText, ifOpenDialog: ifOpenDialog, ); } /// Returns a new [UserException], by merging the current one with the given [anotherUserException]. /// This simply means the given [anotherUserException] will be used as part of the [reason] of the /// current one. /// /// Note: If any of the exceptions has [ifOpenDialog] set to `false`, the result will also /// have [ifOpenDialog] set to `false`. /// @useResult @mustBeOverridden @override UserException mergedWith(UserException? anotherUserException) { if (anotherUserException == null) return this; else { UserException exception = super.mergedWith(anotherUserException); return AdvancedUserException( exception.message, reason: exception.reason, code: exception.code, onOk: onOk, onCancel: onCancel, hardCause: hardCause, props: props, errorText: (anotherUserException.errorText?.isNotEmpty ?? false) ? anotherUserException.errorText : errorText, ifOpenDialog: ifOpenDialog && anotherUserException.ifOpenDialog, ); } } @useResult @mustBeOverridden @override UserException withDialog(bool ifOpenDialog) => AdvancedUserException( message, reason: reason, code: code, onOk: onOk, onCancel: onCancel, props: props, hardCause: hardCause, errorText: errorText, ifOpenDialog: ifOpenDialog, ); @useResult @mustBeOverridden @override UserException withErrorText(String? newErrorText) => AdvancedUserException( message, reason: reason, code: code, onOk: onOk, onCancel: onCancel, props: props, hardCause: hardCause, errorText: newErrorText, ifOpenDialog: ifOpenDialog, ); @override String toString() { return super.toString() + (props.isEmpty ? '' : props.toString()); } } extension UserExceptionAdvancedExtension on UserException { // /// The `onOk` callback of the exception, or `null` if it was not defined. VoidCallback? get onOk { var exception = this; return (exception is AdvancedUserException) ? exception.onOk : null; } /// The `onCancel` callback of the exception, or `null` if it was not defined. VoidCallback? get onCancel { var exception = this; return (exception is AdvancedUserException) ? exception.onCancel : null; } /// The hard cause is some error which caused the [UserException], but that is not /// a [UserException] itself. For example: `int.parse('a')` throws a `FormatException`. /// Then: `throw UserException('Invalid number').addCause(FormatException('Invalid input'))`. /// will have the `FormatException` as the hard cause. Note: If a [UserException] is /// passed as the hard cause, it will be added with [addCause], and will not become the /// hard cause. In other words, a [UserException] will never be a hard cause. Object? get hardCause { var exception = this; return (exception is AdvancedUserException) ? exception.hardCause : null; } /// The properties added to the exception, if any. /// They are an immutable-map of type [IMap], of key-value pairs. /// To read the properties, use the `[]` operator, like this: /// ```dart /// var value = exception.props['key']; /// ``` /// If the key does not exist, it will return `null`. /// IMap get props { var exception = this; return (exception is AdvancedUserException) ? exception.props : const IMapConst({}); } /// Returns a [UserException] from the current one, by adding the given [cause]. /// Note the added [cause] won't replace the original cause, but will be added to it. /// /// If the added [cause] is a `null`, it will return the current exception, unchanged. /// /// If the added [cause] is a [String], the [addReason] method will be used to /// return the new exception. /// /// If the added [cause] is a [UserException], the [mergedWith] method will be used to /// return the new exception. /// /// If the added [cause] is any other type, including any other error types, it will be /// set as the property [hardCause] of the exception. The hard cause is meant to be some /// error which caused the [UserException], but that is not a [UserException] itself. /// For example, if `int.parse('a')` throws a `FormatException`, then /// `throw UserException('Invalid number').addCause(FormatException('Invalid input'))`. /// will set the `FormatException` as the hard cause. /// @useResult UserException addCause(Object? cause) { // if (cause == null) { return this; } // else if (cause is String) { return addReason(cause); } // else if (cause is UserException) { return mergedWith(cause); } // // Now we're going to set the hard cause. else { return AdvancedUserException( message, reason: reason, code: code, onOk: onOk, onCancel: onCancel, props: props, errorText: errorText, ifOpenDialog: ifOpenDialog, hardCause: cause, // We discard the old hard cause, if any. ); } } /// Adds callbacks to the [UserException]. /// /// This method is used to add `onOk` and `onCancel` callbacks to the [UserException]. /// /// The [onOk] callback will be called when the user taps OK in the error dialog. /// The [onCancel] callback will be called when the user taps CANCEL in the error dialog. /// /// If the exception already had callbacks, the new callbacks will be merged with the old ones, /// and the old callbacks will be called before the new ones. /// @useResult UserException addCallbacks({ VoidCallback? onOk, VoidCallback? onCancel, }) { var exception = this; if (exception is AdvancedUserException) { VoidCallback? _onOk; VoidCallback? _onCancel; if (exception.onOk == null) _onOk = onOk; else _onOk = () { exception.onOk?.call(); onOk?.call(); }; if (exception.onCancel == null) _onCancel = onCancel; else _onCancel = () { exception.onCancel?.call(); onCancel?.call(); }; return AdvancedUserException( message, reason: reason, code: code, onOk: _onOk, onCancel: _onCancel, props: exception.props, hardCause: exception.hardCause, errorText: errorText, ifOpenDialog: ifOpenDialog, ); } // else return AdvancedUserException( message, reason: reason, code: code, errorText: errorText, ifOpenDialog: ifOpenDialog, onOk: onOk, onCancel: onCancel, props: const IMapConst({}), hardCause: null, ); } /// Adds [moreProps] to the properties of the [UserException]. /// If the exception already had [props], the new [moreProps] will be merged with those. /// @useResult UserException addProps(Map? moreProps) { if (moreProps == null) return this; else return AdvancedUserException( message, reason: reason, code: code, onOk: onOk, onCancel: onCancel, props: props.addMap(moreProps), hardCause: hardCause, errorText: errorText, ifOpenDialog: ifOpenDialog, ); } } /// If you want the [UserExceptionDialog] to display some [UserException], /// you must throw the exception from inside an action's `before` or `reduce` /// methods. /// /// However, sometimes you need to create some callback that throws /// an [UserException]. If this callback is be called outside of an action, /// the dialog will not display the exception. To solve this, the callback /// should not throw an exception, but instead call the [UserExceptionAction], /// which will then simply throw the exception in its `reduce` method. /// class UserExceptionAction extends ReduxAction { final UserException exception; UserExceptionAction( /// Some message shown to the user. /// Example: `dispatch(UserExceptionAction('Invalid number'))` String? message, { // /// Optionally, instead of [message] we may provide a numeric [code]. /// This code may have an associated message which is set in the client. /// Example: `dispatch(UserExceptionAction('', code: 12))` int? code, /// Another message which is the reason of the user-exception. /// Example: `dispatch(UserExceptionAction('Invalid number', reason: 'Must be less than 42'))` String? reason, /// Callback to be called after the user views the error and taps OK. VoidCallback? onOk, /// Callback to be called after the user views the error and taps CANCEL. VoidCallback? onCancel, /// Adds the given `cause` to the exception. /// * If the added `cause` is a `String`, the `addReason` method will be used to /// create the exception. /// * If the added `cause` is a `UserException`, the `mergedWith` method will /// be used to create the exception. /// * If the added `cause` is any other type, including any other error types, it will be /// set as the property `hardCause` of the exception. The hard cause is meant to be some /// error which caused the `UserException`, but that is not a `UserException` itself. /// For example: `dispatch(UserException('Invalid number', cause: FormatException('Invalid input'))`. /// will set the `FormatException` as the hard cause. Object? cause, /// Any key-value pair properties you'd like to add to the exception. /// For example: `props: {'name': 'John', 'age': 42}` Map? props, /// If `true`, the [UserExceptionDialog] will show in the dialog or similar UI. /// If `false` you can still show the error in a different way, usually showing [errorText] /// in the UI element that is responsible for the error. final bool ifOpenDialog = true, /// Some text to be displayed in the UI element that is responsible for the error. /// For example, a text field could show this text in its `errorText` property. /// When building your widgets, you can get the [errorText] from the failed action: /// `String errorText = context.exceptionFor(MyAction)?.errorText`. final String? errorText, // }) : this.from( UserException( message, reason: reason, code: code, ifOpenDialog: ifOpenDialog, errorText: errorText, ).addCause(cause).addProps(props), ); UserExceptionAction.from(this.exception); @override Future reduce() async => throw exception; } ================================================ FILE: lib/src/cache.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:weak_map/weak_map.dart' as c; /// Cache for 1 immutable state, and no parameters. /// /// The first time this function is called with some state, it will calculate the result from it, /// and then return the result. When this function is called again with the same state (compared /// with the previous one by identity) it will return the same result, without having to calculate /// again. When this function is called again with a different from the previous one, it will /// evict the cache, recalculate the result, and cache it. /// /// Example: /// ``` /// var selector = cache1((int limit) => /// () => /// stateNames.take(limit).toList()); /// ``` Result Function() Function(State1) cache1state( Result Function() Function(State1) f, ) => c.cache1state(f); /// Cache for 1 immutable state, and 1 parameter. /// /// When this function is called with some state and some parameter, it will check if it has /// the cached result for this state/parameter combination. If so, it will return it from the cache, /// without having to recalculate it again. If the result for this state/parameter combination is /// not yet cached, it will calculate it, cache it, and then return it. Note: The cache has one /// entry for each different parameter (comparing parameters by EQUALITY). /// /// Cache eviction: Each time this function is called with some state, it will compare it (by /// IDENTITY) with the state from the previous time the function was called. If the state is /// different, the cache (for all parameters) will be evicted. In other words, as soon as the state /// changes, it will clear all cached results and start all over again. /// /// Example: /// ``` /// var selector = cache1_1((List state) => /// (String startString) => /// state.where((str) => str.startsWith(startString)).toList()); /// ``` Result Function(Param1) Function(State1) cache1state_1param( Result Function(Param1) Function(State1) f, ) => c.cache1state_1param(f); /// Cache for 1 immutable state, and 2 parameters. /// /// When this function is called with some state and some parameters, it will check if it has /// the cached result for this state/parameters combination. If so, it will return it from the /// cache, without having to recalculate it again. If the result for this state/parameters /// combination is not yet cached, it will calculate it, cache it, and then return it. Note: The /// cache has one entry for each different parameter combination (comparing each parameter in the /// combination by EQUALITY). /// /// Cache eviction: Each time this function is called with some state, it will compare it (by /// IDENTITY) with the state from the previous time the function was called. If the state is /// different, the cache (for all parameters) will be evicted. In other words, as soon as the state /// changes, it will clear all cached results and start all over again. /// /// Example: /// ``` /// var selector = cache1_2((List state) => (String startString, String endString) { /// return state /// .where((str) => str.startsWith(startString) && str.endsWith(endString)).toList(); /// }); /// ``` Result Function(Param1, Param2) Function(State1) cache1state_2params( Result Function(Param1, Param2) Function(State1) f, ) => c.cache1state_2params(f); /// Cache for 2 immutable states, and no parameters. /// /// The first time this function is called with some states, it will calculate the result from them, /// and then return the result. When this function is called again with the same states (compared /// with the previous ones by IDENTITY) it will return the same result, without having to calculate /// again. When this function is called again with any of the states (or both) different from the /// previous ones, it will evict the cache, recalculate the result, and cache it. /// /// Example: /// ``` /// var selector = cache2((List names, int limit) => /// () => names.where((str) => str.startsWith("A")).take(limit).toList()); /// ``` Result Function() Function(State1, State2) cache2states( Result Function() Function(State1, State2) f, ) => c.cache2states(f); /// Cache for 2 immutable states, and 1 parameter. /// /// When this function is called with some states and a parameter, it will check if it has /// the cached result for this states/parameter combination. If so, it will return it from the /// cache, without having to recalculate it again. If the result for this states/parameter /// combination is not yet cached, it will calculate it, cache it, and then return it. Note: The /// cache has one entry for each different parameter (comparing parameters by EQUALITY). /// /// Cache eviction: Each time this function is called with some states, it will compare them (by /// IDENTITY) with the states from the previous time the function was called. If any of the states /// is different, the cache (for all parameters) will be evicted. In other words, as soon as one /// of the states (or both) change, it will clear all cached results and start all over again. /// /// Example: /// ``` /// var selector = cache2states_1param((List names, int limit) => (String searchString) { /// return names.where((str) => str.startsWith(searchString)).take(limit).toList(); /// }); /// ``` Result Function(Param1) Function(State1, State2) cache2states_1param( Result Function(Param1) Function(State1, State2) f, ) => c.cache2states_1param(f); /// Cache for 2 immutable states, and 2 parameters. /// /// When this function is called with some states and parameters, it will check if it has /// the cached result for this states/parameters combination. If so, it will return it from the /// cache, without having to recalculate it again. If the result for this states/parameters /// combination is not yet cached, it will calculate it, cache it, and then return it. Note: The /// cache has one entry for each different parameter combination (comparing the parameters in the /// combination by EQUALITY). /// /// Cache eviction: Each time this function is called with some states, it will compare them (by /// IDENTITY) with the states from the previous time the function was called. If any of the states /// is different, the cache (for all parameters) will be evicted. In other words, as soon as one /// of the states (or both) change, it will clear all cached results and start all over again. /// /// Example: /// ``` /// var selector = /// cache2states_2params((List names, int limit) => (String startString, String endString) { /// return names /// .where((str) => str.startsWith(startString) && str.endsWith(endString)) /// .take(limit).toList(); /// }); /// ``` Result Function(Param1, Param2) Function(State1, State2) cache2states_2params( Result Function(Param1, Param2) Function(State1, State2) f, ) => c.cache2states_2params(f); /// Cache for 2 immutable states, and 3 parameters. /// /// When this function is called with some states and parameters, it will check if it has /// the cached result for this states/parameters combination. If so, it will return it from the /// cache, without having to recalculate it again. If the result for this states/parameters /// combination is not yet cached, it will calculate it, cache it, and then return it. Note: The /// cache has one entry for each different parameter combination (comparing the parameters in the /// combination by EQUALITY). /// /// Cache eviction: Each time this function is called with some states, it will compare them (by /// IDENTITY) with the states from the previous time the function was called. If any of the states /// is different, the cache (for all parameters) will be evicted. In other words, as soon as one /// of the states (or both) change, it will clear all cached results and start all over again. /// /// Example: /// ``` /// var selector = /// cache2states_3params((List names, int limit) => (String startString, String endString) { /// return names /// .where((str) => str.startsWith(startString) && str.endsWith(endString)) /// .take(limit).toList(); /// }); /// ``` Result Function(Param1, Param2, Param3) Function(State1, State2) cache2states_3params( Result Function(Param1, Param2, Param3) Function(State1, State2) f, ) => c.cache2states_3params(f); /// Cache for 3 immutable states, and no parameters. /// Example: /// /// The first time this function is called with some states, it will calculate the result from them, /// and then return the result. When this function is called again with the same states (compared /// with the previous ones by IDENTITY) it will return the same result, without having to calculate /// again. When this function is called again with any of the states (or both) different from the /// previous ones, it will evict the cache, recalculate the result, and cache it. /// /// ``` /// var selector = cache3states((List names, int limit, String prefix) => /// () => names.where((str) => str.startsWith(prefix)).take(limit).toList()); /// ``` Result Function() Function(State1, State2, State3) cache3states( Result Function() Function(State1, State2, State3) f, ) => c.cache3states(f); /// Cache for 1 immutable state, no parameters, and some extra information. This is the same /// as [cache1state] but with an extra information. Note: The extra information is not used in /// any way to decide whether the cache should be used/recalculated/evicted. It's just passed down /// to the [f] function to be used during the result calculation. Result Function() Function(State1, Extra) cache1state_0params_x( Result Function() Function(State1, Extra) f, ) => c.cache1state_0params_x(f); /// Cache for 2 immutable states, no parameters, and some extra information. This is the same /// as [cache1state] but with an extra information. Note: The extra information is not used in /// any way to decide whether the cache should be used/recalculated/evicted. It's just passed down /// to the [f] function to be used during the result calculation. Result Function() Function(State1, State2, Extra) cache2states_0params_x( Result Function() Function(State1, State2, Extra) f, ) => c.cache2states_0params_x(f); /// Cache for 3 immutable states, no parameters, and some extra information.This is the same /// as [cache1state] but with an extra information. Note: The extra information is not used in /// any way to decide whether the cache should be used/recalculated/evicted. It's just passed down /// to the [f] function to be used during the result calculation. Result Function() Function(State1, State2, State3, Extra) cache3states_0params_x( Result Function() Function(State1, State2, State3, Extra) f, ) => c.cache3states_0params_x(f); ================================================ FILE: lib/src/cloud_sync.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; abstract class CloudSync extends Persistor {} ================================================ FILE: lib/src/connection_exception.dart ================================================ import 'package:async_redux/async_redux.dart'; /// The [ConnectionException] is a type of [UserException] that warns the user when the connection /// is not working. Use [ConnectionException.noConnectivity] for a simple version that warns the /// users they should check the connection. Use factory [create] to give more complete messages, /// indicating the host that is having problems. /// class ConnectionException extends AdvancedUserException { // // Usage: `throw ConnectionException.noConnectivity`; static const noConnectivity = ConnectionException(); /// Usage: `throw ConnectionException.noConnectivityWithRetry(() {...})`; /// /// A dialog will open. When the user presses OK or dismisses the dialog in any way, /// the [onRetry] callback will be called. /// static ConnectionException noConnectivityWithRetry( void Function()? onRetry) => ConnectionException(onRetry: onRetry); /// Creates a [ConnectionException]. /// /// If you pass it an [onRetry] callback, it will call it when the user presses /// the "Ok" button in the dialog. Otherwise, it will just close the dialog. /// /// If you pass it a [host], it will say "It was not possible to connect to $host". /// Otherwise, it will simply say "There is no Internet connection". /// const ConnectionException({ void Function()? onRetry, this.host, String? errorText, bool ifOpenDialog = true, }) : super( (host == null || host == 'null') ? 'There is no Internet' : 'It was not possible to connect to $host.', reason: 'Please, verify your connection.', code: null, onOk: onRetry, onCancel: null, hardCause: null, errorText: errorText ?? 'No Internet connection', ifOpenDialog: ifOpenDialog, ); final String? host; @override UserException addReason(String? reason) { throw UnsupportedError('You cannot use this.'); } @override UserException mergedWith(UserException? anotherUserException) { throw UnsupportedError('You cannot use this.'); } @override UserException withErrorText(String? newErrorText) => ConnectionException( host: host, onRetry: onOk, errorText: newErrorText, ifOpenDialog: ifOpenDialog, ); @override UserException withDialog(bool ifOpenDialog) => ConnectionException( host: host, onRetry: onOk, errorText: errorText, ifOpenDialog: ifOpenDialog, ); @override UserException get noDialog => ConnectionException( host: host, onRetry: onOk, errorText: errorText, ifOpenDialog: ifOpenDialog, ); } ================================================ FILE: lib/src/connector_tester.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:flutter/material.dart' hide Action; import '../async_redux.dart'; /// Helps testing the `StoreConnector`s methods, such as `onInit`, /// `onDispose` and `onWillChange`. /// /// For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// /// Example: Suppose you have a `StoreConnector` which dispatches `SomeAction` /// on its `onInit`. How could you test that? /// /// ``` /// class MyConnector extends StatelessWidget { /// Widget build(BuildContext context) => StoreConnector( /// vm: () => _Factory(), /// onInit: _onInit, /// builder: (context, vm) { ... } /// } /// /// void _onInit(Store store) => store.dispatch(SomeAction()); /// } /// /// var storeTester = StoreTester(...); /// ConnectorTester(tester, MyConnector()).runOnInit(); /// var info = await tester.waitUntil(SomeAction); /// ``` /// class ConnectorTester { final Store store; final StatelessWidget widgetConnector; StoreConnector? _storeConnector; StoreConnector get storeConnector => _storeConnector ??= // ignore: invalid_use_of_protected_member widgetConnector.build(StatelessElement(widgetConnector)) as StoreConnector; ConnectorTester(this.store, this.widgetConnector); void runOnInit() { final OnInitCallback? onInit = storeConnector.onInit; if (onInit != null) onInit(store); } void runOnDispose() { final OnDisposeCallback? onDispose = storeConnector.onDispose; if (onDispose != null) onDispose(store); } void runOnWillChange( Model previousVm, Model newVm, ) { final OnWillChangeCallback? onWillChange = storeConnector.onWillChange; if (onWillChange != null) onWillChange(StatelessElement(widgetConnector), store, previousVm, newVm); } } ================================================ FILE: lib/src/error_observer.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; /// This is DEPRECATED. Use [GlobalErrorObserver] instead. /// /// The [observe] method of the [ErrorObserver] will be given all errors. /// It's called after the action's [ReduxAction.wrapError] and the [GlobalWrapError] /// have both been called. /// /// The [observe] method should return `true` to throw the error, and `false` to swallow it. /// /// Note: The [ErrorObserver] will be given all errors, including those of type [UserException] /// and [AbortDispatchException]. To maintain the default behavior, you should return `false` /// (swallow) for both these error types. /// /// Important: Don't use the `store` you get in the [observe] method to dispatch any actions, /// as this may have unpredictable results. Also, make sure your errorObserver never throws an /// error. @Deprecated('Use GlobalErrorObserver instead. This will be removed.') abstract class ErrorObserver { // /// The [observe] method of the [ErrorObserver] will be given all errors. /// It's called after the action's [ReduxAction.wrapError] and the [GlobalWrapError] /// have both been called. /// /// The [observe] method should return `true` to throw the error, and `false` to swallow it. /// /// Note: The [ErrorObserver] will be given all errors, including those of type [UserException] /// and [AbortDispatchException]. To maintain the default behavior, you should return `false` /// (swallow) for both these error types. /// /// Important: Don't use the `store` you get in the [observe] method to dispatch any actions, /// as this may have unpredictable results. Also, make sure your errorObserver never throws an /// error. bool observe( Object error, StackTrace stackTrace, ReduxAction action, Store store, ); } /// During development you may use this error observer if you want all errors to be /// shown to the user in a dialog, not only [UserException]s. In more detail: /// This will wrap all errors into [UserException]s, and put them all into the /// error queue. Note that errors which are NOT originally [UserException]s will /// still be thrown, while [UserException]s will still be swallowed. /// /// Passe it to the store like this: /// /// `var store = Store(errorObserver:DevelopmentErrorObserver());` @Deprecated('Use GlobalErrorObserverForDevelopment instead. This will be removed.') class DevelopmentErrorObserver implements ErrorObserver { @override bool observe( Object error, StackTrace stackTrace, ReduxAction action, Store store, ) { if (error is UserException) return false; else { // We have to dispatch another action, since we cannot do: // store._addError(errorAsUserException); // store._changeController.add(store.state); Future.microtask(() => store.dispatch( UserExceptionAction(error.toString(), cause: error), )); return true; } } } /// Swallows all errors (not recommended). Passe it to the store like this: /// `var store = Store(errorObserver: SwallowErrorObserver());` /// @Deprecated('Use SwallowGlobalErrorObserver instead. This will be removed.') class SwallowErrorObserver implements ErrorObserver { @override bool observe( Object error, StackTrace stackTrace, ReduxAction action, Store store, ) { return false; } } ================================================ FILE: lib/src/event_redux.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:math'; import 'package:flutter/foundation.dart'; /// When the [Event] class was created, Flutter did not have any class named /// `Event`. Now there is. For this reason, this typedef allows you to use Evt /// instead. You can hide one of them, by importing AsyncRedux like this: /// import 'package:async_redux/async_redux.dart' hide Event; /// or /// import 'package:async_redux/async_redux.dart' hide Evt; typedef Evt = Event; /// Events are one-time notifications stored in the Redux state, used to trigger /// side effects in widgets such as showing dialogs, clearing text fields, or /// navigating to new screens. /// /// Unlike regular state values, events are automatically "consumed" (marked as /// spent) after being read, ensuring they only trigger once. /// /// ## Main Usage: The `event` Extension /// /// The recommended way to use events is with the `context.event()` extension /// method. First, define an extension in your code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// R? event(Evt Function(AppState state) selector) => getEvent(selector); /// } /// ``` /// /// **Example with a boolean (value-less) event:** /// /// ```dart /// // In your state /// class AppState { /// final Event clearTextEvt; /// AppState({required this.clearTextEvt}); /// } /// /// // In your action /// class ClearTextAction extends ReduxAction { /// @override /// AppState reduce() => state.copy(clearTextEvt: Event()); /// } /// /// // In your widget /// Widget build(BuildContext context) { /// var clearText = context.event((state) => state.clearTextEvt); /// if (clearText) controller.clear(); /// ... /// } /// ``` /// /// **Example with a typed event:** /// /// ```dart /// // In your state /// class AppState { /// final Event changeTextEvt; /// AppState({required this.changeTextEvt}); /// } /// /// // In your action /// class ChangeTextAction extends ReduxAction { /// @override /// Future reduce() async { /// String newText = await fetchTextFromApi(); /// return state.copy(changeTextEvt: Event(newText)); /// } /// } /// /// // In your widget /// Widget build(BuildContext context) { /// var newText = context.event((state) => state.changeTextEvt); /// if (newText != null) controller.text = newText; /// ... /// } /// ``` /// /// ## Return Values /// /// - For events with **no generic type** (`Event`): `Event.consume()` /// returns **true** if the event was dispatched, or **false** if it was /// already spent. /// /// - For events with **a value type** (`Event`): `Event.consume()` returns /// the **value** if the event was dispatched, or **null** if it was already /// spent. /// /// ## Alternative Usage: StoreConnector /// /// Events can also be consumed when creating a `ViewModel` with the `StoreConnector`. /// The event is "consumed" only once in the converter function, and is then /// automatically considered "spent". /// /// ## Important Notes /// /// - Events are consumed only once. After consumption, they are marked as "spent". /// - Each event can be consumed by **one single widget**. /// - Always initialize events as spent: `Event.spent()` or `Event.spent()`. /// - The widget will rebuild when a new event is dispatched, even if it has the /// same internal value as a previous event, because each event instance is /// unique. /// /// For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// /// Note: For `Event()` with no value provided, the value defaults to /// `true` (not `null`), so that `consume()` returns `true` as expected. /// class Event { bool _spent; final T? _evtInfo; Event([T? evtInfo]) : _evtInfo = (T == bool && evtInfo == null) ? (true as T) : evtInfo, _spent = false; Event.spent() : _evtInfo = null, _spent = true; bool get isSpent => _spent; bool get isNotSpent => !isSpent; /// Returns the event state and consumes the event. /// /// After consumption, the event is marked as spent and will not trigger again. /// /// - For events with no generic type (`Event`): Returns **true** if the event /// was dispatched, or **false** if it was already spent. /// /// - For events with a value type (`Event`): Returns the **value** if the /// event was dispatched, or **null** if it was already spent. /// /// This method is called internally by `context.getEvent()` /// (or `context.event()`) so you usually will not use it directly. /// However, when using the dumb/smart widget pattern, you may use it inside /// a dumb widget (for example, when using a `StoreConnector`) when the event /// was passed as a constructor parameter by a smart widget. T? consume() { T? saveState = state; _spent = true; return saveState; } /// Returns the event state without consuming it. /// /// Unlike [consume], this method does not mark the event as spent, so the /// event can be read multiple times. /// /// This is useful in rare cases where you need to check the event value /// without consuming it, but most use cases should use [consume] via /// `context.getEvent()` (or `context.event()`). T? get state { if (T == dynamic && _evtInfo == null) { if (_spent) return (false as T); else { return (true as T); } } else { if (_spent) return null; else { return _evtInfo; } } } @override String toString() => 'Event(' '${state.toString()}' '${_spent == true ? ', spent' : ''}' ')'; /// Creates an event which is transformed by a function that usually needs /// the store state. /// /// You must provide the event and a map-function. The map-function must be /// able to deal with the spent state (null or false, accordingly). /// /// This is useful when you need to derive a new event value from an existing /// event, typically by looking up additional data from the state. /// /// **Example:** If `state.indexEvt = Event(5)` and you need to get a /// user from it: /// /// ```dart /// var mapFunction = (int? index) => index == null ? null : state.users[index]; /// Event userEvt = Event.map(state.indexEvt, mapFunction); /// ``` static Event map(Event evt, T? Function(V?) mapFunction) => MappedEvent(evt, mapFunction); /// Creates an event which consumes from more than one event. /// /// If the first event is not spent, it will be consumed, and the second /// will not. If the first event is spent, the second one will be consumed. /// /// This is useful when you have multiple sources for the same event and want /// to consume from whichever one is available. /// /// **Note:** If both events are NOT spent, the method will have to be called /// twice to consume both. If both are spent, returns `null`. /// /// **Example:** /// ```dart /// Event combinedEvt = Event.from(localMessageEvt, remoteMessageEvt); /// ``` factory Event.from(Event evt1, Event evt2) => EventMultiple(evt1, evt2); /// Consumes from more than one event, prioritizing the first event. /// /// If the first event is not spent, it will be consumed, and the second will /// not. If the first event is spent, the second one will be consumed. /// /// This is useful when you have multiple sources for the same event and want /// to consume from whichever one is available. /// /// **Note:** If both events are NOT spent, the method will have to be called /// twice to consume both. If both are spent, returns null. /// /// **Example:** /// ```dart /// String? message = Event.consumeFrom(localMessageEvt, remoteMessageEvt); /// ``` static T? consumeFrom(Event evt1, Event evt2) { T? evt = evt1.consume(); evt ??= evt2.consume(); return evt; } /// Special equality implementation for events to ensure correct rebuild /// behavior. /// /// Events use a custom equality check where: /// - **Unspent events** are never considered equal to any other event, /// ensuring widgets always rebuild when a new event is dispatched. /// - **Spent events** are all considered equal to each other, since they are /// "empty" and should not trigger rebuilds. /// /// This behavior is essential for both the `context.event()` extension and /// `StoreConnector` usage patterns. /// /// ## For StoreConnector Users /// /// When using a [StoreConnector], you must implement equals and hashcode for /// your `ViewModel`. Events included in the ViewModel must follow these rules: /// /// 1) If the **new** ViewModel has an event which is **not spent**, then the /// ViewModel **MUST** be considered distinct, no matter the state of the /// **old** ViewModel, since the new event should fire. /// /// 2) If both the old and new ViewModels have events which **are spent**, /// then these events **MUST NOT** be considered distinct, since spent events /// are considered "empty" and should never fire. /// /// 3) If the **new** ViewModel has an event which is **not spent**, and /// the **old** ViewModel has an event which **is spent**, then the new event /// should fire, and for that reason they **MUST** be considered distinct. /// /// 4) If the **new** ViewModel has an event which is **is spent**, and /// the **old** ViewModel has an event which **not spent**, then the new event /// should NOT fire, and for that reason they **SHOULD NOT** be considered /// distinct. /// /// **Note:** To differentiate cases 3 and 4 we would actually be breaking /// the equals contract (which says A==B should be the same as B==A). A safer /// alternative is to always consider events different if any of them is not /// spent. That will, however, fire some unnecessary rebuilds. @override bool operator ==(Object other) { return identical(this, other) || other is Event && runtimeType == other.runtimeType /// 1) Events not spent are never considered equal to any other, /// and they will always "fire", forcing the widget to rebuild. /// 2) Spent events are considered "empty", so they are all equal. && (isSpent && other.isSpent); } /// 1) If two objects are equal according to the equals method, then hashcode /// of both must be the same. Since spent events are all equal, they should /// produce the same hashcode. /// 2) If two objects are NOT equal, hashcode may be the same or not, but it's /// better when they are not the same. However, events are mutable, and this /// could mean the hashcode of the state could be changed when an event is /// consumed. To avoid this, we make events always return the same hashCode. @override int get hashCode => 0; } /// An event that combines multiple sub-events, consuming them in priority order. /// /// When consuming this event: /// - If the first sub-event is not spent, it will be consumed, and the second /// will not. /// - If the first sub-event is spent, the second one will be consumed. /// /// This is useful when you have multiple sources for the same event and want /// to consume from whichever one is available. /// /// **Note:** If both sub-events are NOT spent, the multiple-event will have to /// be consumed twice to consume both sub-events. If both sub-events are spent, /// returns null when consumed. /// /// **Example:** /// ```dart /// Event combinedEvt = EventMultiple(localMessageEvt, remoteMessageEvt); /// ``` class EventMultiple extends Event { Event evt1; Event evt2; EventMultiple(Event? evt1, Event? evt2) : evt1 = evt1 as Event? ?? Event.spent(), evt2 = evt2 as Event? ?? Event.spent(); // Is spent only if both are spent. @override bool get isSpent => evt1.isSpent && evt2.isSpent; /// Returns the event state and consumes the event. /// /// Consumes the first non-spent event. If the first event is not spent, it /// will be consumed and returned. Otherwise, the second event will be /// consumed and returned. @override T? consume() { return Event.consumeFrom(evt1, evt2); } /// Returns the event state without consuming it. /// /// Returns the state of the first non-spent event without consuming either event. @override T? get state { T? st = evt1.state; st ??= evt2.state; return st; } } /// An event whose value is transformed by a mapping function. /// /// This is useful when your event value must be transformed by a function that /// usually needs the store state. You must provide the event and a map-function. /// The map-function must be able to deal with the spent state (null or false, /// accordingly). /// /// This is commonly used when you need to derive a new event value from an /// existing event, typically by looking up additional data from the state. /// /// **Example:** If `state.indexEvt = Event(5)` and you need to get a user /// from it: /// /// ```dart /// var mapFunction = (int? index) => index == null ? null : state.users[index]; /// Event userEvt = MappedEvent(state.indexEvt, mapFunction); /// ``` class MappedEvent extends Event { Event evt; T? Function(V?) mapFunction; MappedEvent(Event? evt, this.mapFunction) : evt = evt ?? Event.spent(); @override bool get isSpent => evt.isSpent; /// Returns the transformed event state and consumes the underlying event. @override T? consume() => mapFunction(evt.consume()); /// Returns the transformed event state without consuming it. @override T? get state => mapFunction(evt.state); } /// An event-like class that generates a "pulse" to trigger widget updates, /// but is NEVER CONSUMED. /// /// Unlike [Event] which is consumed after being read, [EvtState] can be used /// with multiple widgets and will trigger rebuilds each time a new instance is /// created. /// /// Each [EvtState] instance is unique, even if created with the same value: /// /// ```dart /// print(EvtState() == EvtState()); // false /// print(EvtState('abc') == EvtState('abc')); // false /// ``` /// /// **Usage with stateful widgets:** /// /// When a new [EvtState] is created in the state, it will trigger a widget /// rebuild. Then, the `didUpdateWidget` method will be called. Since `evt` /// is now different from `oldWidget.evt`, it will run your side effect: /// /// ```dart /// @override /// void didUpdateWidget(MyWidget oldWidget) { /// super.didUpdateWidget(oldWidget); /// /// if (evt != oldWidget.evt) doSomethingWith(evt.value); /// } /// ``` /// /// **Key difference from Event:** /// /// The [EvtState] class is never "consumed" (like the [Event] class is), which /// means you can use it with more than one widget. Use [EvtState] when you need /// multiple widgets to react to the same trigger. Use [Event] when you need /// one-time consumption by a single widget. /// /// Note: For `Evt()` with no value provided, the value defaults to /// `true` (not `null`), so that `consume()` returns `true` as expected. /// @immutable class EvtState { static final _random = Random.secure(); final T? value; final int _rand; EvtState([this.value]) : _rand = _random.nextInt(1 << 32); @override bool operator ==(Object other) => identical(this, other) || other is EvtState && runtimeType == other.runtimeType && value == other.value && _rand == other._rand; @override int get hashCode => value.hashCode ^ _rand.hashCode; } ================================================ FILE: lib/src/global_wrap_error.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; /// This is DEPRECATED. Use [GlobalErrorObserver] instead. /// /// This will be given all errors thrown in your actions (including those of type /// `UserException`). Then: /// * If it returns the same [error] unaltered, this original error will be used. /// * If it returns something else, that it will be used instead of the original [error]. /// * If it returns `null`, the original error will be disabled (swallowed). /// /// IMPORTANT: If instead of RETURNING an error you THROW an error inside the `wrap` function, /// AsyncRedux will catch this error and use it instead the original error. In other /// words, returning an error or throwing an error has the same effect. However, it is still /// recommended to return the error rather than throwing it. /// /// Note this wrapper is called AFTER the action's [ReduxAction.wrapError], /// and BEFORE the [ErrorObserver]. /// /// A common use case for this is to have a global place to convert some /// exceptions into [UserException]s. For example, Firebase may throw some /// `PlatformException`s in response to a bad connection to the server. /// In this case, you may want to show the user a dialog explaining that the /// connection is bad, which you can do by converting it to a [UserException]. /// Note, this could also be done in the [ReduxAction.wrapError], but then /// you'd have to add it to all actions that use Firebase. /// /// Another use case is when you want to throw the [AdvancedUserException.hardCause] /// which is not itself an [UserException], and you still want to show /// the original [UserException] in a dialog to the user: /// ``` /// Object wrap(Object error, [StackTrace stackTrace, ReduxAction action]) { /// if (error is UserException) { /// var hardCause = error.hardCause(); /// if (hardCause != null) { /// Future.microtask(() => /// Business.store.dispatch(UserExceptionAction.from(error.withoutHardCause()))); /// return hardCause; /// }} /// return null; } /// ``` /// /// You should not use [GlobalWrapError] to log errors, as the preferred place for /// doing that is in the [ErrorObserver]. @Deprecated('Use GlobalErrorObserver instead. Check the documentation for more details.') abstract class GlobalWrapError { Object? wrap( Object error, StackTrace stackTrace, ReduxAction action, ); } /// A dummy global wrap error that does nothing. @Deprecated('Use GlobalErrorObserver instead. This will be removed.') class GlobalWrapErrorDummy implements GlobalWrapError { @override Object? wrap(error, stackTrace, action) => error; } ================================================ FILE: lib/src/local_json_persist.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:async_redux/src/persistor.dart'; import 'package:file/file.dart' as f; import 'package:file/local.dart'; import 'package:path/path.dart' as p; import 'local_persist.dart'; /// Save a simple-object in a file, in UTF-8 Json format. /// /// Use [save] to save as Json: /// /// ```dart /// var persist = LocalJsonPersist("xyz"); /// var simpleObj = "Hello"; /// await persist.saveJson(simpleObj); /// ``` /// /// Use [load] to load from Json: /// /// ```dart /// var persist = LocalJsonPersist("xyz"); /// Object? decoded = await persist.loadJson(); /// ``` /// /// Examples of valid JSON includes: /// 42 /// 42.5 /// "abc" /// [1, 2, 3] /// ["42", 123] /// {"42": 123} /// /// /// Examples of invalid JSON includes: /// [1, 2, 3][4, 5, 6] // Not valid because Json does not allow two separate objects. /// 1, 2, 3 // Not valid because Json does not allow comma separated objects. /// 'abc' // Not valid because string must use double quotes. /// {42: "123"} // Not valid because a map key must be of type string. /// class LocalJsonPersist { // /// The default is saving/loading to/from "appDocsDir/db/". /// This is not final, so you can change it. /// Make it an empty string to remove it. static String defaultDbSubDir = "db"; /// If running from Flutter, the default base directory is the application's documents dir. /// If running from tests (detected by the `LocalFileSystem` not being present), /// it will use the system's temp directory. /// /// You can change this variable to globally change the directory: /// ``` /// // Will use the application's cache directory. /// LocalPersist.useBaseDirectory = LocalPersist.useAppCacheDir; /// /// // Will use the application's downloads directory. /// LocalPersist.useBaseDirectory = LocalPersist.useAppDownloadsDir; /// /// // Will use whatever Directory is given. /// LocalPersist.useBaseDirectory = () => LocalPersist.useCustomBaseDirectory(baseDirectory: myDir); /// ``` static Future Function() useBaseDirectory = useAppDocumentsDir; /// The default is adding a ".json" termination to the file name. static const String jsonTermination = ".json"; static Directory? get appDocDir => _baseDirectory; static Directory? get _baseDirectory => LocalPersist.appDocDir; static f.FileSystem get _fileSystem => LocalPersist.getFileSystem(); final String? dbName, dbSubDir; final List? subDirs; final f.FileSystem _fileSystemRef; File? _file; /// Saves to `appDocsDir/db/${dbName}.json` /// /// If [dbName] is a String, it will be used as such. /// If [dbName] is an enum, it will use only the enum value itself. /// For example if `files` is an enum, then `LocalJsonPersist(files.abc)` /// is the same as `LocalJsonPersist("abc")` /// If [dbName] is another object type, its [toString] will be called, /// and then the text after the last dot will be used. /// /// The default database directory [defaultDbSubDir] is `db`. /// You can change this variable to globally change the directory, /// or provide [dbSubDir] in the constructor. /// /// You can also provide other [subDirs] as Strings or enums. /// Example: `LocalJsonPersist("photos", subDirs: ["article", "images"])` /// saves to `appDocsDir/db/article/images/photos.db` /// /// Important: /// — In tests, instead of using `appDocsDir` it will save to /// the system temp dir. /// — If you mock the file-system (see method `setFileSystem()`) /// it will save to `fileSystem.systemTempDirectory`. /// LocalJsonPersist(Object dbName, {this.dbSubDir, List? subDirs}) : dbName = _getStringFromEnum(dbName), subDirs = subDirs?.map((s) => _getStringFromEnum(s)).toList(), _file = null, _fileSystemRef = _fileSystem; /// Saves to the given file. LocalJsonPersist.from(File file) : dbName = null, dbSubDir = null, subDirs = null, _file = file, _fileSystemRef = _fileSystem; /// If running from Flutter, this will get the application's documents directory. /// If running from tests, it will use the system's temp directory. static Future useAppDocumentsDir() => LocalPersist.useAppDocumentsDir(); /// If running from Flutter, this will get the application's cache directory. /// If running from tests, it will use the system's temp directory. static Future useAppCacheDir() => LocalPersist.useAppCacheDir(); /// If running from Flutter, this will get the application's downloads directory. /// If running from tests, it will use the system's temp directory. static Future useAppDownloadsDir() => LocalPersist.useAppDownloadsDir(); /// If running from Flutter, the base directory will be the given [baseDirectory]. /// If running from tests, it will use the optional [testDirectory], or if this is not provided, /// it will use the system's temp directory. static Future useCustomBaseDirectory({ required Directory baseDirectory, Directory? testDirectory, }) => LocalPersist.useCustomBaseDirectory( baseDirectory: baseDirectory, testDirectory: testDirectory); /// Saves the given simple object as JSON. /// If the file exists, it will be overwritten. Future save(Object? simpleObj) async { _checkIfFileSystemIsTheSame(); Uint8List encoded = encodeJson(simpleObj); File file = _file ?? await this.file(); await file.create(recursive: true); return file.writeAsBytes( encoded, flush: true, mode: FileMode.writeOnly, ); } /// Loads a simple-object from a JSON file. If the file doesn't exist, returns null. /// A JSON can be a String, a number, null, true, false, '{' (a map) or ']' (a list). /// Note: The file must contain a single JSON, and it can't be empty. It can, however /// simple contain 'null' (without the quotes) which will return null. Future load() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (!file.existsSync()) return null; else { Uint8List encoded; try { encoded = await file.readAsBytes(); } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return null; rethrow; } Object? simpleObjs = decodeJson(encoded); return simpleObjs; } } /// This method can be used if you were using a Json sequence file with a ".db" termination, /// and wants to convert it to a regular Json file. This only works if your original ".db" /// file has a single object. /// /// 1) It first loads a Json file called "[dbName].json". /// - If the file exists and is NOT empty, return its content as a single simple object. /// - If the file exists and is empty, returns null. /// - If the file doesn't exist, goes to step 2. /// /// 2) Next, tries loading a Json-SEQUENCE file called "[dbName].db". /// - If the file doesn't exist, returns null. /// - If the file exists and is empty, saves it as an empty Json file called "[dbName].json" /// - If the file exists with a single object, saves it as a Json file called "[dbName].json" /// - If the file exists and has 2 or more objects: /// * If [isList] is false, throws an exception. /// * If [isList] is true, wraps the result in a List. /// - Then deletes the "[dbName].db" file (always deletes, no matter what happens). /// /// Note: In effect, this will convert all files it loads from a Json-sequence to Json. /// This only works if the original ".db" file is a Json-sequence file, and it's on you /// to make sure that's the case. /// Future loadConverting({required bool isList}) async { // _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (!file.existsSync()) return _readsFromJsonSequenceDbFile(isList); else { Uint8List encoded; try { // Loads the '.json' (Json) file. encoded = await file.readAsBytes(); } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return _readsFromJsonSequenceDbFile(isList); rethrow; } Object? simpleObjs = decodeJson(encoded); return simpleObjs; } } /// Reads a Json-sequence from a '.db' file. Future _readsFromJsonSequenceDbFile(bool isList) async { // /// Prepares to open the '.db' file with the same name and location. var jsonSequenceFile = LocalPersist(dbName!, dbSubDir: dbSubDir, subDirs: subDirs); // If the '.db' (Json-sequence) file exists, if (await jsonSequenceFile.exists()) { // // Loads the '.db' file into memory. List? objs = await jsonSequenceFile.load(); // Deletes the Json-sequence file. jsonSequenceFile.delete(); if (isList) { objs ??= const []; // Saves the '.json' (Json) file, so that it loads directly, next time. await save(objs); return objs; } // // Not a list. else { if (objs != null && objs.length > 1) throw PersistException( "Json sequence to Json: ${objs.length} objects: $objs."); // else { // Saves the '.json' (Json) file, so that it loads directly, next time. var obj = (objs == null || objs.isEmpty) ? null : objs[0]; await save(obj); return obj; } } } // else return null; } /// Same as [load], but expects the file to be a Map /// representing a single object. Will fail if it's not a map. It may return null. Future?> loadAsObj() async { Object? simpleObj = await load(); if (simpleObj == null) return null; if (simpleObj is! Map) throw PersistException("Not an object: $simpleObj"); return simpleObj; } /// Same as [loadConverting], but expects the file to be a Map /// representing a single object. Will fail if it's not a map. It may return null. Future?> loadAsObjConverting() async { Object? simpleObj = await loadConverting(isList: false); if (simpleObj == null) return null; if (simpleObj is! Map) throw PersistException("Not an object: $simpleObj"); return simpleObj; } /// Deletes the file. /// If the file was deleted, returns true. /// If the file did not exist, return false. Future delete() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (file.existsSync()) { try { file.deleteSync(recursive: true); return true; } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return false; rethrow; } } else return false; } /// Returns the file length. /// If the file doesn't exist, or exists and is empty, returns 0. Future length() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (!file.existsSync()) return 0; else { try { return file.length(); } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return 0; rethrow; } } } /// Returns true if the file exist. False, otherwise. Future exists() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); return file.existsSync(); } // If the fileSystemRef has changed, files will have to be recreated. void _checkIfFileSystemIsTheSame() { if (!identical(_fileSystemRef, _fileSystem)) _file = null; } /// Gets the file. Future file() async { if (_file != null) return _file!; else { if (_baseDirectory == null) await useBaseDirectory(); String pathNameStr = pathName( dbName, dbSubDir: dbSubDir, subDirs: subDirs, ); _file = _fileSystem.file(pathNameStr); return _file!; } } static String? simpleObjsToString(List? simpleObjs) => // simpleObjs == null ? simpleObjs as String? : simpleObjs.map((obj) => "$obj (${obj.runtimeType})").join("\n"); static String pathName( String? dbName, { String? dbSubDir, List? subDirs, }) { return p.joinAll([ LocalJsonPersist._baseDirectory!.path, dbSubDir ?? LocalJsonPersist.defaultDbSubDir, if (subDirs != null) ...subDirs, "$dbName${LocalJsonPersist.jsonTermination}" ]); } static String _getStringFromEnum(Object dbName) => (dbName is String) ? dbName : dbName.toString().split(".").last; /// Decodes a single JSON into a simple object, from the given [bytes]. static Object? decodeJson(Uint8List bytes) { ByteBuffer buffer = bytes.buffer; Uint8List info = Uint8List.view(buffer); var utf8Decoder = const Utf8Decoder(); String json = utf8Decoder.convert(info); var jsonDecoder = const JsonDecoder(); return jsonDecoder.convert(json); } /// Decodes a single simple object into a JSON, from the given [simpleObj]. static Uint8List encodeJson(Object? simpleObj) { var jsonEncoder = const JsonEncoder(); String json = jsonEncoder.convert(simpleObj); Utf8Encoder encoder = const Utf8Encoder(); Uint8List encoded = encoder.convert(json); return encoded; } /// You can set a memory file-system in your tests. For example: /// ``` /// final mfs = MemoryFileSystem(); /// setUpAll(() { LocalJsonPersist.setFileSystem(mfs); }); /// tearDownAll(() { LocalJsonPersist.resetFileSystem(); }); /// ... /// expect(mfs.file('myPic.jpg').readAsBytesSync(), List.filled(100, 0)); /// ``` static void setFileSystem(f.FileSystem fileSystem) { LocalPersist.setFileSystem(fileSystem); } static void resetFileSystem() => LocalPersist.setFileSystem(const LocalFileSystem()); } ================================================ FILE: lib/src/local_persist.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:async_redux/async_redux.dart'; import 'package:file/file.dart' as f; import 'package:file/local.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; /// This will save/load objects into the local disk, as a '.json' file. /// /// ========================================================= /// /// 1) Save a simple object in UTF-8 Json format. /// /// Use [saveJson] to save as Json: /// /// ```dart /// var persist = LocalPersist("xyz"); /// var simpleObj = "Hello"; /// await persist.saveJson(simpleObj); /// ``` /// /// Use [loadJson] to load from Json: /// /// ```dart /// var persist = LocalPersist("xyz"); /// Object? decoded = await persist.loadJson(); /// ``` /// /// Examples of valid JSON includes: /// 42 /// 42.5 /// "abc" /// [1, 2, 3] /// ["42", 123] /// {"42": 123} /// /// /// Examples of invalid JSON includes: /// [1, 2, 3][4, 5, 6] // Not valid because Json does not allow two separate objects. /// 1, 2, 3 // Not valid because Json does not allow comma separated objects. /// 'abc' // Not valid because string must use double quotes. /// {42: "123"} // Not valid because a map key must be of type string. /// /// ========================================================= /// /// 2) Save multiple simple objects in a concatenation of UTF-8 Json sequence. /// Note: A Json sequence is NOT valid Json. /// /// Use [save] to save a list of objects as a Json sequence: /// /// ```dart /// var persist = LocalPersist("xyz"); /// List simpleObjs = ['"Hello"', '"How are you?"', [1, 2, 3], 42]; /// await persist.save(); /// ``` /// /// The save method has an [append] parameter. If [append] is false (the default), /// the file will be overwritten. If [append] is true, it will write to the end /// of the file. Being able to append is the only advantage of saving as a Json /// sequence instead of saving in regular Json. If you don't need to append, /// use [saveJson] instead of [save]. /// /// Also, a limitation is that, in a json sequence, each object may have at most /// 65.536 bytes. Note this refers to a single json object, not to the total json /// sequence file, which may contain many objects. /// /// Use [load] to load a list of objects from a Json sequence: /// /// ```dart /// var persist = LocalPersist("xyz"); /// List decoded = await persist.load(); /// ``` /// class LocalPersist { // /// The default is saving/loading to/from "appDocsDir/db/". /// This is not final, so you can change it. /// Make it an empty string to remove it. static String defaultDbSubDir = "db"; /// If running from Flutter, the default base directory is the application's documents dir. /// If running from tests (detected by the `LocalFileSystem` not being present), /// it will use the system's temp directory. /// /// You can change this variable to globally change the directory: /// ``` /// // Will use the application's cache directory. /// LocalPersist.useBaseDirectory = LocalPersist.useAppCacheDir; /// /// // Will use the application's downloads directory. /// LocalPersist.useBaseDirectory = LocalPersist.useAppDownloadsDir; /// /// // Will use whatever Directory is given. /// LocalPersist.useBaseDirectory = () => LocalPersist.useCustomBaseDirectory(baseDirectory: myDir); /// ``` static Future Function() useBaseDirectory = LocalPersist.useAppDocumentsDir; /// The default is adding a ".db" termination to the file name. /// This is not final, so you can change it. static String defaultTermination = ".db"; static Directory? get appDocDir => _baseDirectory; static Directory? _baseDirectory; // In a json sequence, each object may have at most 65.536 bytes. // Note this refers to a single json object, not to the total json sequence file, // which may contain many objects. static const maxJsonSize = 256 * 256; static f.FileSystem _fileSystem = const LocalFileSystem(); final String? dbName, dbSubDir; final List? subDirs; final f.FileSystem _fileSystemRef; File? _file; /// Saves to `appDocsDir/db/${dbName}.db` /// /// If [dbName] is a String, it will be used as such. /// If [dbName] is an enum, it will use only the enum value itself. /// For example if `files` is an enum, then `LocalPersist(files.abc)` /// is the same as `LocalPersist("abc")` /// If [dbName] is another object type, a toString() will be done, /// and then the text after the last dot will be used. /// /// The default database directory [defaultDbSubDir] is `db`. /// You can change this variable to globally change the directory, /// or provide [dbSubDir] in the constructor. /// /// You can also provide other [subDirs] as Strings or enums. /// Example: `LocalPersist("photos", subDirs: ["article", "images"])` /// saves to `appDocsDir/db/article/images/photos.db` /// /// Important: /// — In tests, instead of using `appDocsDir` it will save to /// the system temp dir. /// — If you mock the file-system (see method `setFileSystem()`) /// it will save to `fileSystem.systemTempDirectory`. /// LocalPersist(Object dbName, {this.dbSubDir, List? subDirs}) : dbName = _getStringFromEnum(dbName), subDirs = subDirs?.map((s) => _getStringFromEnum(s)).toList(), _file = null, _fileSystemRef = _fileSystem; /// Saves to the given file. LocalPersist.from(File file) : dbName = null, dbSubDir = null, subDirs = null, _file = file, _fileSystemRef = _fileSystem; /// Saves the given simple objects. /// If [append] is false (the default), the file will be overwritten. /// If [append] is true, it will write to the end of the file. Future save(List simpleObjs, {bool append = false}) async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); await file.create(recursive: true); Uint8List encoded = LocalPersist.encode(simpleObjs); return file.writeAsBytes( encoded, flush: true, mode: append ? FileMode.writeOnlyAppend : FileMode.writeOnly, ); } /// Saves the given simple object as JSON (but in a '.db' file). /// If the file exists, it will be overwritten. Future saveJson(Object? simpleObj) async { _checkIfFileSystemIsTheSame(); Uint8List encoded = encodeJson(simpleObj); File file = _file ?? await this.file(); await file.create(recursive: true); return file.writeAsBytes( encoded, flush: true, mode: FileMode.writeOnly, ); } /// Loads the simple objects from the file. /// If the file doesn't exist, returns null. /// If the file exists and is empty, returns an empty list. Future?> load() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (!file.existsSync()) return null; else { Uint8List encoded; try { encoded = await file.readAsBytes(); } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return null; rethrow; } List simpleObjs = decode(encoded); return simpleObjs; } } /// Loads an object from a JSON file ('.db' file). /// If the file doesn't exist, returns null. /// Note: The file must contain a single JSON, which is NOT /// the default file-format for [LocalPersist]. Future? loadJson() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (!file.existsSync()) return null; else { Uint8List encoded; try { encoded = await file.readAsBytes(); } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return null; rethrow; } Object? simpleObjs = decodeJson(encoded); return simpleObjs; } } /// Same as [load], but expects the file to be a Map /// representing a single object. Will fail if it's not a map, /// or if contains more than one single object. It may return null. Future?> loadAsObj() async { List? simpleObjs = await load(); if (simpleObjs == null) return null; if (simpleObjs.length != 1) throw PersistException("Not a single object: $simpleObjs"); var simpleObj = simpleObjs[0]; if ((simpleObj != null) && (simpleObj is! Map)) throw PersistException("Not an object: $simpleObj"); return simpleObj as FutureOr?>; } /// Deletes the file. /// If the file was deleted, returns true. /// If the file did not exist, return false. Future delete() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (file.existsSync()) { try { file.deleteSync(recursive: true); return true; } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return false; rethrow; } } else return false; } /// Returns the file length. /// If the file doesn't exist, or exists and is empty, returns 0. Future length() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); if (!file.existsSync()) return 0; else { try { return file.length(); } catch (error) { if ((error is FileSystemException) && // error.message.contains("No such file or directory")) return 0; rethrow; } } } /// Returns true if the file exist. False, otherwise. Future exists() async { _checkIfFileSystemIsTheSame(); File file = _file ?? await this.file(); return file.existsSync(); } // If the fileSystemRef has changed, files will have to be recreated. void _checkIfFileSystemIsTheSame() { if (!identical(_fileSystemRef, _fileSystem)) _file = null; } /// Gets the file. Future file() async { if (_file != null) return _file!; else { if (_baseDirectory == null) await useBaseDirectory(); String pathNameStr = pathName( dbName, dbSubDir: dbSubDir, subDirs: subDirs, ); _file = _fileSystem.file(pathNameStr); return _file!; } } static String? simpleObjsToString(List? simpleObjs) => // simpleObjs == null ? simpleObjs as String? : simpleObjs.map((obj) => "$obj (${obj.runtimeType})").join("\n"); static String pathName( String? dbName, { String? dbSubDir, List? subDirs, }) { return p.joinAll([ LocalPersist._baseDirectory!.path, dbSubDir ?? LocalPersist.defaultDbSubDir, if (subDirs != null) ...subDirs, "$dbName${LocalPersist.defaultTermination}" ]); } static String _getStringFromEnum(Object dbName) => (dbName is String) ? dbName : dbName.toString().split(".").last; /// If running from Flutter, the base directory will be the application's documents directory. /// If running from tests, it will use the system's temp directory. static Future useAppDocumentsDir() async { if (_baseDirectory != null) return; if (_fileSystem == const LocalFileSystem()) { try { _baseDirectory = await getApplicationDocumentsDirectory(); } on MissingPluginException catch (_) { _baseDirectory = const LocalFileSystem().systemTempDirectory; } } else _baseDirectory = _fileSystem.systemTempDirectory; } /// If running from Flutter, the base directory will be the application's cache directory. /// If running from tests, it will use the system's temp directory. static Future useAppCacheDir() async { if (_baseDirectory != null) return; if (_fileSystem == const LocalFileSystem()) { try { _baseDirectory = await getApplicationCacheDirectory(); } on MissingPluginException catch (_) { _baseDirectory = const LocalFileSystem().systemTempDirectory; } } else _baseDirectory = _fileSystem.systemTempDirectory; } /// If running from Flutter, the base directory will be the application's downloads directory. /// If running from tests, it will use the system's temp directory. static Future useAppDownloadsDir() async { if (_baseDirectory != null) return; if (_fileSystem == const LocalFileSystem()) { try { _baseDirectory = await getDownloadsDirectory(); } on MissingPluginException catch (_) { _baseDirectory = const LocalFileSystem().systemTempDirectory; } } else _baseDirectory = _fileSystem.systemTempDirectory; } /// If running from Flutter, the base directory will be the given [baseDirectory]. /// If running from tests, it will use the optional [testDirectory], or if this is not provided, /// it will use the system's temp directory. static Future useCustomBaseDirectory({ required Directory baseDirectory, Directory? testDirectory, }) async { if (_baseDirectory != null) return; if (_fileSystem == const LocalFileSystem()) { try { // Calling this just to detect if we are running from Flutter or tests. await getDownloadsDirectory(); _baseDirectory = baseDirectory; } on MissingPluginException catch (_) { _baseDirectory = testDirectory ?? const LocalFileSystem().systemTempDirectory; } } else _baseDirectory = _fileSystem.systemTempDirectory; } static Uint8List encode(List simpleObjs) { Iterable jsons = objsToJsons(simpleObjs); List chunks = jsonsToUint8Lists(jsons); Uint8List encoded = concatUint8Lists(chunks); return encoded; } static Iterable objsToJsons(List simpleObjs) { var jsonEncoder = const JsonEncoder(); return simpleObjs.map((j) => jsonEncoder.convert(j)); } static List jsonsToUint8Lists(Iterable jsons) { List chunks = []; for (String json in jsons) { Utf8Encoder encoder = const Utf8Encoder(); Uint8List bytes = encoder.convert(json); var size = bytes.length; if (size > maxJsonSize) throw PersistException("Size is $size but max is $maxJsonSize bytes."); chunks.add(Uint8List.fromList([size ~/ 256, size % 256])); chunks.add(bytes); } return chunks; } static Uint8List concatUint8Lists(List chunks) { return Uint8List.fromList(chunks.expand((x) => (x)).toList()); } static List decode(Uint8List bytes) { List chunks = bytesToUint8Lists(bytes); Iterable jsons = uint8ListsToJsons(chunks); return toSimpleObjs(jsons).toList(); } /// Decodes a single JSON into a simple object, from the given [bytes]. static Object? decodeJson(Uint8List bytes) { ByteBuffer buffer = bytes.buffer; Uint8List info = Uint8List.view(buffer); var utf8Decoder = const Utf8Decoder(); String json = utf8Decoder.convert(info); var jsonDecoder = const JsonDecoder(); return jsonDecoder.convert(json); } /// Decodes a single simple object into a JSON, from the given [simpleObj]. static Uint8List encodeJson(Object? simpleObj) { var jsonEncoder = const JsonEncoder(); String json = jsonEncoder.convert(simpleObj); Utf8Encoder encoder = const Utf8Encoder(); Uint8List encoded = encoder.convert(json); return encoded; } static List bytesToUint8Lists(Uint8List bytes) { List chunks = []; var buffer = bytes.buffer; int pos = 0; while (pos < bytes.length) { int size = bytes[pos] * 256 + bytes[pos + 1]; Uint8List info = Uint8List.view(buffer, pos + 2, size); chunks.add(info); pos += 2 + size; } return chunks; } static Iterable uint8ListsToJsons(Iterable chunks) { var utf8Decoder = const Utf8Decoder(); return chunks.map((readChunks) => utf8Decoder.convert(readChunks)); } static Iterable toSimpleObjs(Iterable jsons) { var jsonDecoder = const JsonDecoder(); return jsons.map((json) => jsonDecoder.convert(json)); } /// You can set a memory file-system in your tests. For example: /// ``` /// final mfs = MemoryFileSystem(); /// setUpAll(() { LocalPersist.setFileSystem(mfs); }); /// tearDownAll(() { LocalPersist.resetFileSystem(); }); /// ... /// expect(mfs.file('myPic.jpg').readAsBytesSync(), List.filled(100, 0)); /// ``` static void setFileSystem(f.FileSystem fileSystem) { _fileSystem = fileSystem; } static f.FileSystem getFileSystem() => _fileSystem; static void resetFileSystem() => setFileSystem(const LocalFileSystem()); } ================================================ FILE: lib/src/log.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:logging/logging.dart'; /// Connects a [Logger] to the Redux Store. /// Every action that is dispatched will be logged to the Logger, along with the new State /// that was created as a result of the action reaching your Store's reducer. /// /// By default, this class does not print anything to your console or to a web /// service, such as Fabric or Sentry. It simply logs entries to a Logger instance. /// You can then listen to the [Logger.onRecord] Stream, and print to the /// console or send these actions to a web service. /// /// Example: To print actions to the console as they are dispatched: /// /// var store = Store( /// initialValue: 0, /// actionObservers: [Log.printer()]); /// /// Example: If you only want to log actions to a Logger, use the default constructor. /// /// // Create your own Logger and pass it to the Observer. /// final logger = new Logger("Redux Logger"); /// final stateObserver = Log(logger: logger); /// /// final store = new Store( /// initialState: 0, /// stateObserver: [stateObserver]); /// /// // Note: One quirk about listening to a logger instance is that you're /// // actually listening to the Singleton instance of *all* loggers. /// logger.onRecord /// // Filter down to [LogRecord]s sent to your logger instance /// .where((record) => record.loggerName == logger.name) /// // Print them out (or do something more interesting!) /// .listen((LogRecord) => print(LogRecord)); /// class Log implements ActionObserver { // final Logger logger; /// The log Level at which the actions will be recorded final Level level; /// A function that formats the String for printing final MessageFormatter formatter; /// Logs actions to the given Logger, and does not print anything to the console. Log({ Logger? logger, this.level = Level.INFO, this.formatter = singleLineFormatter, }) : logger = logger ?? Logger("Log"); /// Logs actions to the console. factory Log.printer({ Logger? logger, Level level = Level.INFO, MessageFormatter formatter = singleLineFormatter, }) { final log = Log(logger: logger, level: level, formatter: formatter); log.logger.onRecord // .where((record) => record.loggerName == log.logger.name) .listen(print); return log; } /// A very simple formatter that writes only the action. static String verySimpleFormatter( dynamic state, ReduxAction action, bool ini, int dispatchCount, DateTime timestamp, ) => "$action ${ini ? 'INI' : 'END'}"; /// A simple formatter that puts all data on one line. static String singleLineFormatter( dynamic state, ReduxAction action, bool? ini, int dispatchCount, DateTime timestamp, ) { return "{$action, St: $state, ts: ${DateTime.now()}}"; } /// A formatter that puts each attribute on it's own line. static String multiLineFormatter( dynamic state, ReduxAction action, bool ini, int dispatchCount, DateTime timestamp, ) { return "{\n" " $dispatchCount) $action,\n" " St: $state,\n" " Timestamp: ${DateTime.now()}\n" "}"; } @override void observe(ReduxAction action, int dispatchCount, {required bool ini}) { logger.log( level, formatter(null, action, ini, dispatchCount, DateTime.now()), ); } } // /// A function that formats the message that will be logged: /// /// final log = Log(formatter: onlyLogActionFormatter); /// var store = new Store(initialState: 0, actionObservers:[log], stateObservers: [...]); /// typedef MessageFormatter = String Function( St? state, ReduxAction action, bool ini, int dispatchCount, DateTime timestamp, ); ================================================ FILE: lib/src/mock_build_context.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/foundation.dart' show DiagnosticsTreeStyle; import 'package:flutter/material.dart'; /// A mock BuildContext that holds a Store reference, for testing purposes. /// /// This allows you to test smart widgets created with context extensions. /// For example, suppose this is your smart widget: /// /// ```dart /// class MyConnector extends StoreConnector { /// Widget build(BuildContext context) { /// return MyWidget(name: context.state.name); /// } /// } /// ``` /// /// This is how you can test it with: /// /// ```dart /// // First, create a Store with the desired initial state. /// var store = Store(initialState: AppState(name: 'Mark'); /// /// // Then, create a mock BuildContext with that store. /// var context = MockBuildContext(store); /// /// // Instantiate your StoreConnector or StoreProvider and build the widget. /// var widget = MyConnector().build(context) as MyWidget; /// expect(widget.name, 'Mark'); /// ``` /// class MockBuildContext extends BuildContext { final Store store; MockBuildContext(this.store) { // Create the static store backdoor. StoreProvider(store: store, child: const SizedBox()); } @override Widget get widget => const Placeholder(); @override bool get debugDoingBuild => true; @override InheritedWidget dependOnInheritedElement(InheritedElement ancestor, {Object? aspect}) { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override T? dependOnInheritedWidgetOfExactType( {Object? aspect}) { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override DiagnosticsNode describeElement(String name, {DiagnosticsTreeStyle style = DiagnosticsTreeStyle.errorProperty}) { return DiagnosticsNode.message('Not implemented in MockBuildContext.'); } @override List describeMissingAncestor( {required Type expectedAncestorType}) { return [DiagnosticsNode.message('Not implemented in MockBuildContext.')]; } @override DiagnosticsNode describeOwnershipChain(String name) { return DiagnosticsNode.message('Not implemented in MockBuildContext.'); } @override DiagnosticsNode describeWidget(String name, {DiagnosticsTreeStyle style = DiagnosticsTreeStyle.errorProperty}) { return DiagnosticsNode.message('Not implemented in MockBuildContext.'); } @override void dispatchNotification(Notification notification) { // Do nothing. } @override T? findAncestorRenderObjectOfType() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override T? findAncestorStateOfType>() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override T? findAncestorWidgetOfExactType() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override RenderObject? findRenderObject() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override T? findRootAncestorStateOfType>() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override InheritedElement? getElementForInheritedWidgetOfExactType() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override T? getInheritedWidgetOfExactType() { throw UnimplementedError('Not implemented in MockBuildContext.'); } @override bool get mounted => true; @override BuildOwner? get owner => null; @override Size? get size => throw UnimplementedError('Not implemented in MockBuildContext.'); @override void visitAncestorElements(ConditionalElementVisitor visitor) { // Do nothing. } @override void visitChildElements(ElementVisitor visitor) { // Do nothing. } } ================================================ FILE: lib/src/mock_store.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'package:async_redux/async_redux.dart'; /// Creates a Redux store that lets you mock actions/reducers. /// /// The MockStore lets you define mock actions/reducers for specific actions. /// /// For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// class MockStore extends Store { MockStore({ required St initialState, Object? environment, Map props = const {}, bool syncStream = false, TestInfoPrinter? testInfoPrinter, List>? actionObservers, List>? stateObservers, Persistor? persistor, Persistor? cloudSync, ModelObserver? modelObserver, ErrorObserver? errorObserver, WrapReduce? wrapReduce, GlobalErrorObserver Function(Store)? globalErrorObserver, // @Deprecated("Use `globalErrorObserver` instead. This will be removed.") GlobalWrapError? globalWrapError, // bool? defaultDistinct, CompareBy? immutableCollectionEquality, int? maxErrorsQueued, this.mocks, }) : super( initialState: initialState, environment: environment, props: props, syncStream: syncStream, testInfoPrinter: testInfoPrinter, actionObservers: actionObservers, stateObservers: stateObservers, persistor: persistor, cloudSync: cloudSync, modelObserver: modelObserver, errorObserver: errorObserver, wrapReduce: wrapReduce, globalErrorObserver: globalErrorObserver, globalWrapError: globalWrapError, defaultDistinct: defaultDistinct, immutableCollectionEquality: immutableCollectionEquality, maxErrorsQueued: maxErrorsQueued, ); /// 1) `null` to disable dispatching the action of a certain type. /// /// 2) A `MockAction` instance to dispatch that action instead, /// and provide the original action as a getter to the mocked action. /// /// 3) A `ReduxAction` instance to dispatch that mocked action instead. /// /// 4) `ReduxAction Function(ReduxAction)` to create a mock /// from the original action. /// /// 5) `St Function(ReduxAction, St)` or /// `Future Function(ReduxAction, St)` to modify the state directly. /// Map? mocks; MockStore addMock(Type actionType, dynamic mock) { (mocks ??= {})[actionType] = mock; return this; } MockStore addMocks(Map mocks) { (this.mocks ??= {}).addAll(mocks); return this; } MockStore clearMocks() { mocks = null; return this; } /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. /// /// ```dart /// store.dispatch(MyAction()); /// ``` /// /// Method [dispatch] is of type [Dispatch]. /// /// See also: /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. @override FutureOr dispatch( ReduxAction action, { bool notify = true, }) { ReduxAction? _action = _getMockedAction(action); return (_action == null) // ? Future.value(ActionStatus(context: (action, this))) : super.dispatch(_action, notify: notify); } @Deprecated("Use `dispatchAndWait` instead. This will be removed.") @override Future dispatchAsync(ReduxAction action, {bool notify = true}) { return dispatchAndWait(action, notify: notify); } /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. In both cases, it returns a [Future] that resolves when /// the action finishes. /// /// ```dart /// await store.dispatchAndWait(DoThisFirstAction()); /// store.dispatch(DoThisSecondAction()); /// ``` /// /// Note: While the state change from the action's reducer will have been applied when the /// Future resolves, other independent processes that the action may have started may still /// be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns `Future`, /// which means you can also get the final status of the action after you `await` it: /// /// ```dart /// var status = await store.dispatchAndWait(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. @override Future dispatchAndWait(ReduxAction action, {bool notify = true}) { ReduxAction? _action = _getMockedAction(action); return (_action == null) // ? Future.value(ActionStatus(context: (action, this))) : super.dispatchAndWait(_action, notify: notify); } /// Dispatches the action, applying its reducer, and possibly changing the store state. /// However, if the action is ASYNC, it will throw a [StoreException]. /// /// Method [dispatchSync] is of type [DispatchSync]. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. @override ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) { ReduxAction? _action = _getMockedAction(action); return (_action == null) // ? ActionStatus(context: (action, this)) : super.dispatchSync(_action, notify: notify); } ReduxAction? _getMockedAction(ReduxAction action) { if (mocks == null || !mocks!.containsKey(action.runtimeType)) return action; else { var mock = mocks![action.runtimeType]; // 1) `null` to disable dispatching the action of a certain type. if (mock == null) return null; // // 2) A `MockAction` instance to dispatch that action instead, // and provide the original action as a getter to the mocked action. else if (mock is MockAction) { mock._setAction(action); return mock; } // // 3) A `ReduxAction` instance to dispatch that mocked action instead. else if (mock is ReduxAction) { return mock as ReduxAction; } // // 4) `ReduxAction Function(ReduxAction)` to create a mock // from the original action. else if (mock is ReduxAction Function(ReduxAction)) { ReduxAction mockAction = mock(action); return mockAction; } // // 5) `St Function(ReduxAction, St)` or // `Future Function(ReduxAction, St)` to modify the state directly. else if (mock is St Function(ReduxAction, St)) { MockAction mockAction = _GeneralActionSync(mock); mockAction._setAction(action); return mockAction; } else if (mock is Future Function(ReduxAction, St)) { MockAction mockAction = _GeneralActionAsync(mock); mockAction._setAction(action); return mockAction; } // else throw StoreException("Action of type `${action.runtimeType}` " "can't be mocked by a mock of type " "`${mock.runtimeType}`.\n" "Valid mock types are:\n" "`null`\n" "`MockAction`\n" "`ReduxAction`\n" "`ReduxAction Function(ReduxAction)`\n" "`St Function(ReduxAction, St)`\n"); } } } abstract class MockAction extends ReduxAction { late ReduxAction _action; ReduxAction get action => _action; void _setAction(ReduxAction action) { _action = action; } } class _GeneralActionSync extends MockAction { final St Function(ReduxAction action, St state) _reducer; _GeneralActionSync(this._reducer); @override St reduce() => _reducer(action, state); } class _GeneralActionAsync extends MockAction { final Future Function(ReduxAction action, St state) _reducer; _GeneralActionAsync(this._reducer); @override Future reduce() => _reducer(action, state); } ================================================ FILE: lib/src/model_observer.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; /// The [ModelObserver] is rarely used. It's goal is to observe and troubleshoot the model changes /// causing rebuilds. While you may subclass it to implement its [observe] method, usually you can /// just use the provided [DefaultModelObserver] to print the StoreConnector's ViewModel to the /// console. /// abstract class ModelObserver { // /// The [ModelObserver] can be used to observe and troubleshoot the model changes. /// /// The [storeConnector] works by rebuilding the widget when the model changes. /// It needs to compare the [modelPrevious] with the [modelCurrent] to decide if the widget should /// rebuild: /// /// - [isDistinct] is `true` means the widget rebuilt because the model changed. /// - [isDistinct] is `false` means the widget didn't rebuilt because the model hasn't changed. /// - [isDistinct] is `null` means the widget rebuilds everytime (because of /// the `StoreConnector.distinct` parameter), and the model is not relevant. void observe({ required Model? modelPrevious, required Model? modelCurrent, bool? isDistinct, StoreConnectorInterface? storeConnector, int? reduceCount, int? dispatchCount, }); } /// The [DefaultModelObserver] prints the StoreConnector's ViewModel to the console. /// /// Passe it to the store like this: /// /// `var store = Store(modelObserver:DefaultModelObserver());` /// /// If you need to print the type of the `StoreConnector` to the console, /// make sure to pass `debug:this` as a `StoreConnector` constructor parameter. /// Then, optionally, you can also specify a list of `StoreConnector`s to be /// observed: /// /// `DefaultModelObserver([MyStoreConnector, SomeOtherStoreConnector]);` /// /// You can also override your `ViewModels.toString()` to print out /// any extra info you need. /// class DefaultModelObserver implements ModelObserver { Model? _previous; Model? _current; Model? get previous => _previous; Model? get current => _current; final List _storeConnectorTypes; DefaultModelObserver([this._storeConnectorTypes = const []]); @override void observe({ required Model? modelPrevious, required Model? modelCurrent, bool? isDistinct, StoreConnectorInterface? storeConnector, int? reduceCount, int? dispatchCount, }) { _previous = modelPrevious; _current = modelCurrent; var shouldObserve = _storeConnectorTypes.isEmpty || _storeConnectorTypes.contains(storeConnector!.debug?.runtimeType); if (shouldObserve) print("Model D:$dispatchCount R:$reduceCount = " "Rebuild:${isDistinct == null || isDistinct}, " "${storeConnector!.debug == null ? "" : // "Connector:${storeConnector.debug.runtimeType}"}, " "Model:$modelCurrent."); } } ================================================ FILE: lib/src/navigate_action.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:flutter/material.dart'; import '../async_redux.dart'; /// Available constructors: /// `NavigateAction.push()`, /// `NavigateAction.pop()`, /// `NavigateAction.popAndPushNamed()`, /// `NavigateAction.pushNamed()`, /// `NavigateAction.pushReplacement()`, /// `NavigateAction.pushAndRemoveUntil()`, /// `NavigateAction.replace()`, /// `NavigateAction.replaceRouteBelow()`, /// `NavigateAction.pushReplacementNamed()`, /// `NavigateAction.pushNamedAndRemoveUntil()`, /// `NavigateAction.pushNamedAndRemoveAll()`, /// `NavigateAction.popUntil()`, /// `NavigateAction.removeRoute()`, /// `NavigateAction.removeRouteBelow()`, /// `NavigateAction.popUntilRouteName()`, /// `NavigateAction.popUntilRoute()`, /// class NavigateAction extends ReduxAction { static GlobalKey? _navigatorKey; static GlobalKey? get navigatorKey => _navigatorKey; static void setNavigatorKey(GlobalKey navigatorKey) => _navigatorKey = navigatorKey; /// Trick explained here: https://github.com/flutter/flutter/issues/20451 /// Note 'ModalRoute.of(context).settings.name' doesn't always work. static String? getCurrentNavigatorRouteName(BuildContext context) { late Route currentRoute; Navigator.popUntil(context, (route) { currentRoute = route; return true; }); return currentRoute.settings.name; } NavigateAction._(this.details); final NavigatorDetails details; /// This is useful for tests only. /// You can test that some dispatched NavigateAction was of a certain type. NavigateType get type => details.type; @override St? reduce() { details.navigate(); return null; } NavigateAction.push( Route route, ) : this._(NavigatorDetails_Push(route)); NavigateAction.pop([Object? result]) : this._(NavigatorDetails_Pop(result)); NavigateAction.popAndPushNamed( String routeName, { Object? result, Object? arguments, }) : this._(NavigatorDetails_PopAndPushNamed(routeName, result: result, arguments: arguments)); NavigateAction.pushNamed( String routeName, { Object? arguments, }) : this._(NavigatorDetails_PushNamed(routeName, arguments: arguments)); NavigateAction.pushReplacement( Route route, { Object? result, }) : this._(NavigatorDetails_PushReplacement(route, result: result)); NavigateAction.pushAndRemoveUntil( Route route, RoutePredicate predicate, ) : this._(NavigatorDetails_PushAndRemoveUntil(route, predicate)); NavigateAction.replace({ Route? oldRoute, Route? newRoute, }) : this._(NavigatorDetails_Replace( oldRoute: oldRoute, newRoute: newRoute, )); NavigateAction.replaceRouteBelow({ Route? anchorRoute, Route? newRoute, }) : this._(NavigatorDetails_ReplaceRouteBelow( anchorRoute: anchorRoute, newRoute: newRoute, )); NavigateAction.pushReplacementNamed( String routeName, { Object? arguments, }) : this._(NavigatorDetails_PushReplacementNamed(routeName, arguments: arguments)); NavigateAction.pushNamedAndRemoveUntil( String newRouteName, RoutePredicate predicate, { Object? arguments, }) : this._(NavigatorDetails_PushNamedAndRemoveUntil(newRouteName, predicate, arguments: arguments)); NavigateAction.pushNamedAndRemoveAll( String newRouteName, { Object? arguments, }) : this._(NavigatorDetails_PushNamedAndRemoveAll(newRouteName, arguments: arguments)); NavigateAction.popUntil( RoutePredicate predicate, ) : this._(NavigatorDetails_PopUntil(predicate)); NavigateAction.removeRoute( Route route, ) : this._(NavigatorDetails_RemoveRoute(route)); NavigateAction.removeRouteBelow( Route anchorRoute, ) : this._(NavigatorDetails_RemoveRouteBelow(anchorRoute)); NavigateAction.popUntilRouteName( String routeName, { bool ifPrintRoutes = false, }) : this._(NavigatorDetails_PopUntilRouteName(routeName, ifPrintRoutes: ifPrintRoutes)); NavigateAction.popUntilRoute( Route route, ) : this._(NavigatorDetails_PopUntilRoute(route)); @override String toString() => '${super.toString()}${details.toString()}'; } class NavigatorDetails_Push implements NavigatorDetails { final Route route; NavigatorDetails_Push(this.route); @override void navigate() { NavigateAction._navigatorKey?.currentState?.push(route); } @override NavigateType get type => NavigateType.push; @override String toString() => '.push(${route.toStringOrRuntimeType()})'; } class NavigatorDetails_Pop implements NavigatorDetails { final Object? result; NavigatorDetails_Pop(this.result); @override void navigate() { NavigateAction._navigatorKey?.currentState?.pop(result); } @override NavigateType get type => NavigateType.pop; @override String toString() => '.pop(${result == null ? "" : result.toStringOrRuntimeType()})'; } class NavigatorDetails_PopAndPushNamed implements NavigatorDetails { final String routeName; final Object? result; final Object? arguments; NavigatorDetails_PopAndPushNamed( this.routeName, { this.result, this.arguments, }); @override void navigate() { NavigateAction._navigatorKey?.currentState?.popAndPushNamed( routeName, result: result, arguments: arguments, ); } @override NavigateType get type => NavigateType.popAndPushNamed; @override String toString() => '.popAndPushNamed(' '$routeName' '${result == null ? "" : ", result: " + result.toStringOrRuntimeType()})'; } class NavigatorDetails_PushNamed implements NavigatorDetails { final String routeName; final Object? arguments; NavigatorDetails_PushNamed( this.routeName, { this.arguments, }); @override void navigate() { NavigateAction._navigatorKey?.currentState ?.pushNamed(routeName, arguments: arguments); } @override NavigateType get type => NavigateType.pushNamed; @override String toString() => '.pushNamed($routeName)'; } class NavigatorDetails_PushReplacementNamed implements NavigatorDetails { final String routeName; final Object? arguments; NavigatorDetails_PushReplacementNamed( this.routeName, { this.arguments, }); @override void navigate() { NavigateAction._navigatorKey?.currentState ?.pushReplacementNamed(routeName, arguments: arguments); } @override NavigateType get type => NavigateType.pushReplacementNamed; @override String toString() => '.pushReplacementNamed($routeName)'; } class NavigatorDetails_PushNamedAndRemoveUntil implements NavigatorDetails { final String newRouteName; final Object? arguments; final RoutePredicate predicate; NavigatorDetails_PushNamedAndRemoveUntil( this.newRouteName, this.predicate, { this.arguments, }); @override void navigate() { NavigateAction._navigatorKey?.currentState?.pushNamedAndRemoveUntil( newRouteName, predicate, arguments: arguments); } @override NavigateType get type => NavigateType.pushNamedAndRemoveUntil; @override String toString() => '.pushNamedAndRemoveUntil($newRouteName, predicate)'; } class NavigatorDetails_PushNamedAndRemoveAll implements NavigatorDetails { final String newRouteName; final Object? arguments; NavigatorDetails_PushNamedAndRemoveAll( this.newRouteName, { this.arguments, }); @override void navigate() { NavigateAction._navigatorKey?.currentState?.pushNamedAndRemoveUntil( newRouteName, (_) => false, arguments: arguments); } @override NavigateType get type => NavigateType.pushNamedAndRemoveAll; @override String toString() => '.pushNamedAndRemoveAll($newRouteName)'; } class NavigatorDetails_PushReplacement implements NavigatorDetails { final Route route; final Object? result; NavigatorDetails_PushReplacement( this.route, { this.result, }); @override void navigate() { NavigateAction._navigatorKey?.currentState ?.pushReplacement(route, result: result); } @override NavigateType get type => NavigateType.pushReplacement; @override String toString() => '.pushReplacement(' '${route.toStringOrRuntimeType()}' '${result == null ? "" : ", result: " + result.toStringOrRuntimeType()})'; } class NavigatorDetails_PushAndRemoveUntil implements NavigatorDetails { final Route route; final RoutePredicate predicate; NavigatorDetails_PushAndRemoveUntil( this.route, this.predicate, ); @override void navigate() { NavigateAction._navigatorKey?.currentState ?.pushAndRemoveUntil(route, predicate); } @override NavigateType get type => NavigateType.pushAndRemoveUntil; @override String toString() => '.pushAndRemoveUntil(' '${route.toStringOrRuntimeType()}' ', predicate)'; } class NavigatorDetails_Replace implements NavigatorDetails { final Route? oldRoute; final Route? newRoute; NavigatorDetails_Replace({ required this.oldRoute, required this.newRoute, }); @override void navigate() { NavigateAction._navigatorKey?.currentState?.replace( oldRoute: oldRoute!, newRoute: newRoute!, ); } @override NavigateType get type => NavigateType.replace; @override String toString() => '.replace(' 'oldRoute: ${oldRoute.toStringOrRuntimeType()}, ' 'newRoute: ${newRoute.toStringOrRuntimeType()})'; } class NavigatorDetails_ReplaceRouteBelow implements NavigatorDetails { final Route? anchorRoute; final Route? newRoute; NavigatorDetails_ReplaceRouteBelow({ required this.anchorRoute, required this.newRoute, }); @override void navigate() { NavigateAction._navigatorKey?.currentState?.replaceRouteBelow( anchorRoute: anchorRoute!, newRoute: newRoute!, ); } @override NavigateType get type => NavigateType.replaceRouteBelow; @override String toString() => '.replaceRouteBelow(' 'anchorRoute: ${anchorRoute.toStringOrRuntimeType()}, ' 'newRoute: ${newRoute.toStringOrRuntimeType()})'; } class NavigatorDetails_PopUntil implements NavigatorDetails { final RoutePredicate predicate; NavigatorDetails_PopUntil(this.predicate); @override void navigate() { NavigateAction._navigatorKey?.currentState?.popUntil(predicate); } @override NavigateType get type => NavigateType.popUntil; @override String toString() => '.popUntil(predicate)'; } class NavigatorDetails_PopUntilRouteName implements NavigatorDetails { final String routeName; /// Make this true if you want to see all the routes printed to the console. /// This doesn't affect the navigation itself. final bool ifPrintRoutes; NavigatorDetails_PopUntilRouteName(this.routeName, {this.ifPrintRoutes = false}); @override void navigate() { NavigateAction._navigatorKey?.currentState?.popUntil(((route) { bool result = (route.settings.name == routeName); if (ifPrintRoutes) print('${route.settings.name} == $routeName ($result)'); return result; })); } @override NavigateType get type => NavigateType.popUntilRouteName; @override String toString() => '.popUntilRouteName($routeName)'; } class NavigatorDetails_PopUntilRoute implements NavigatorDetails { final Route route; NavigatorDetails_PopUntilRoute(this.route); @override void navigate() { NavigateAction._navigatorKey?.currentState ?.popUntil(((_route) => _route == route)); } @override NavigateType get type => NavigateType.popUntilRoute; @override String toString() => '.popUntilRoute(${route.toStringOrRuntimeType()})'; } class NavigatorDetails_RemoveRoute implements NavigatorDetails { final Route route; NavigatorDetails_RemoveRoute(this.route); @override void navigate() { NavigateAction._navigatorKey?.currentState?.removeRoute(route); } @override NavigateType get type => NavigateType.removeRoute; @override String toString() => '.removeRoute(${route.toStringOrRuntimeType()})'; } class NavigatorDetails_RemoveRouteBelow implements NavigatorDetails { final Route anchorRoute; NavigatorDetails_RemoveRouteBelow(this.anchorRoute); @override void navigate() { NavigateAction._navigatorKey?.currentState?.removeRouteBelow(anchorRoute); } @override NavigateType get type => NavigateType.removeRouteBelow; @override String toString() => '.removeRouteBelow(${anchorRoute.toStringOrRuntimeType()})'; } abstract class NavigatorDetails { void navigate(); NavigateType get type; } enum NavigateType { push, pop, popAndPushNamed, pushNamed, pushReplacement, pushAndRemoveUntil, replace, replaceRouteBelow, pushReplacementNamed, pushNamedAndRemoveUntil, pushNamedAndRemoveAll, popUntil, removeRoute, removeRouteBelow, popUntilRouteName, popUntilRoute, } extension _StringExtension on Object? { /// If the object can be represented with up to 200 chars, we print it. /// Otherwise, we use the object's runtimeType without the generic part. String toStringOrRuntimeType() { String text = toString(); if (text.length <= 200) return text; else { text = runtimeType.toString(); var pos = text.indexOf('<'); return (pos == -1) ? text : text.substring(0, text.indexOf('<')); } } } ================================================ FILE: lib/src/persistor.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'package:async_redux/async_redux.dart'; /// Use it like this: /// /// ```dart /// var persistor = MyPersistor(); /// /// var initialState = await persistor.readState(); /// /// if (initialState == null) { /// initialState = AppState.initialState(); /// await persistor.saveInitialState(initialState); /// } /// /// var store = Store( /// initialState: initialState, /// persistor: persistor, /// ); /// ``` /// /// IMPORTANT: When the store is created with a Persistor, the store considers that the /// provided initial-state was already persisted. You have to make sure this is the case. /// abstract class Persistor { // /// Read the saved state from the persistence. Should return null if the state is not yet /// persisted. This method should be called only once, when the app starts, before the store /// is created. The state it returns may become the store's initial-state. If some error /// occurs while loading the info, we have to deal with it by fixing the problem. In the worse /// case, if we think the state is corrupted and cannot be fixed, one alternative is deleting /// all persisted files and returning null. Future readState(); /// Delete the saved state from the persistence. Future deleteState(); /// Save the new state to the persistence. /// [lastPersistedState] is the last state that was persisted since the app started, /// while [newState] is the new state to be persisted. /// /// Note you have to make sure that [newState] is persisted after this method is called. /// For simpler apps where your state is small, you can just ignore [lastPersistedState] /// and persist the whole [newState] every time. But for larger apps, you should compare /// [lastPersistedState] and [newState], to persist only the difference between them. Future persistDifference({ required St? lastPersistedState, required St newState, }); /// Save an initial-state to the persistence. Future saveInitialState(St state) => persistDifference(lastPersistedState: null, newState: state); /// The default throttle is 2 seconds. Pass null to turn off throttle. Duration? get throttle => const Duration(seconds: 2); } /// A decorator to print persistor information to the console. /// Use it like this: /// /// ```dart /// var store = Store(..., persistor: PersistorPrinterDecorator(persistor)); /// ``` /// class PersistorPrinterDecorator extends Persistor { final Persistor _persistor; PersistorPrinterDecorator(this._persistor); @override Future readState() async { print("Persistor: read state."); return _persistor.readState(); } @override Future deleteState() async { print("Persistor: delete state."); return _persistor.deleteState(); } @override Future persistDifference({ required St? lastPersistedState, required St newState, }) async { print("Persistor: persist difference:\n" "lastPersistedState = $lastPersistedState\n" "newState = newState"); return _persistor.persistDifference( lastPersistedState: lastPersistedState, newState: newState); } @override Future saveInitialState(St state) async { print("Persistor: save initial state."); return _persistor.saveInitialState(state); } @override Duration? get throttle => _persistor.throttle; } /// A dummy persistor. /// class PersistorDummy extends Persistor { @override Future readState() async => null; @override Future deleteState() async => null; @override Future persistDifference( {required lastPersistedState, required newState}) async {} @override Future saveInitialState(T state) async {} @override Duration? get throttle => null; } class PersistException implements Exception { final Object error; PersistException(this.error); @override String toString() => error.toString(); @override bool operator ==(Object other) => identical(this, other) || other is PersistException // && runtimeType == other.runtimeType // && error == other.error; @override int get hashCode => error.hashCode; } class PersistAction extends ReduxAction { @override St? reduce() => null; } ================================================ FILE: lib/src/process_persistence.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'package:async_redux/async_redux.dart'; class ProcessPersistence { // ProcessPersistence(this.persistor, this.lastPersistedState) : isPersisting = false, isANewStateAvailable = false, lastPersistTime = DateTime.now().toUtc(), isPaused = false, isInit = false; final Persistor persistor; St? lastPersistedState; late St newestState; bool isPersisting; bool isANewStateAvailable; DateTime lastPersistTime; Timer? timer; bool isPaused; bool isInit; Duration get throttle => persistor.throttle ?? const Duration(); /// Same as [Persistor.saveInitialState] but will remember [initialState] as the [lastPersistedState]. Future saveInitialState(St initialState) { lastPersistedState = initialState; return persistor.saveInitialState(initialState); } /// Same as [Persistor.readState] but will remember the read state as the [lastPersistedState]. Future readState() async { St? state = await persistor.readState(); lastPersistedState = state; return state; } /// Same as [Persistor.deleteState] but will clear the [lastPersistedState]. Future deleteState() async { lastPersistedState = null; return persistor.deleteState(); } /// 1) If we're still persisting the last time, don't persist no matter what. /// 2) If throttle period is done (or if action is PersistAction), persist. /// 3) If throttle period is NOT done, create a timer to persist as soon as it finishes. /// /// Return true if the persist process started. /// Return false if persistence was postponed. /// bool process( ReduxAction? action, St newState, ) { isInit = true; newestState = newState; if (isPaused || identical(lastPersistedState, newState)) return false; // 1) If we're still persisting the last time, don't persist no matter what. if (isPersisting) { isANewStateAvailable = true; return false; } // else { // var now = DateTime.now().toUtc(); // 2) If throttle period is done (or if action is PersistAction), persist. if ( // (now.difference(lastPersistTime) >= throttle) // || (action is PersistAction) // ) { _cancelTimer(); _persist(now, newestState); return true; } // // 3) If throttle period is NOT done, create a timer to persist as soon as it finishes. else { if (timer == null) { // Duration asSoonAsThrottleFinishes = throttle - now.difference(lastPersistTime); timer = Timer(asSoonAsThrottleFinishes, () { timer = null; process(null, newestState); }); } return false; } } } void _cancelTimer() { if (timer != null) { timer!.cancel(); timer = null; } } void _persist(DateTime now, newState) async { isPersisting = true; lastPersistTime = now; isANewStateAvailable = false; try { await persistor.persistDifference( lastPersistedState: lastPersistedState, newState: newState, ); } // finally { lastPersistedState = newState; isPersisting = false; // If a new state became available while the present state was saving, save again. if (isANewStateAvailable) { isANewStateAvailable = false; process(null, newestState); } } } /// Pause the [Persistor] temporarily. /// /// When [pause] is called, the Persistor will not start a new persistence process, until method /// [resume] is called. This will not affect the current persistence process, if one is currently /// running. /// /// Note: A persistence process starts when the [persistDifference] method is called, and /// finishes when the future returned by that method completes. /// void pause() { isPaused = true; } /// Persists the current state (if it's not yet persisted), then pauses the [Persistor] /// temporarily. /// /// /// When [persistAndPause] is called, this will not affect the current persistence process, if /// one is currently running. If no persistence process was running, it will immediately start a /// new persistence process (ignoring [throttle]). /// /// Then, the Persistor will not start another persistence process, until method [resume] is /// called. /// /// Note: A persistence process starts when the [persistDifference] method is called, and /// finishes when the future returned by that method completes. /// void persistAndPause() { isPaused = true; _cancelTimer(); if (isInit && !isPersisting && !identical(lastPersistedState, newestState)) { var now = DateTime.now().toUtc(); _persist(now, newestState); } } /// Resumes persistence by the [Persistor], after calling [pause] or [persistAndPause]. void resume() { isPaused = false; if (isInit) process(null, newestState); } } ================================================ FILE: lib/src/redux_action.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux part of async_redux_store; /// All actions you create must extend this class `ReduxAction`. /// /// Important: Do NOT override operator == and hashCode. Actions must retain /// their default [Object] comparison by identity, for AsyncRedux to work. /// /// --- /// /// This class comes with a lot of useful fields and methods: /// /// Most important ones are: /// /// > `state` - Returns current state in the store. This is a getter, and can change after every await, for async actions. /// > `reduce` - The action reducer that returns the new state. Must be overridden. /// > `dispatch` - Dispatches an action (sync or async). /// > `dispatchAndWait` - Dispatches an action and returns a `Future` that resolves when it finishes. /// > `isWaiting` - Checks if a specific action or action type is currently being processed. /// > `isFailed` - Returns true if an action failed with a `UserException`. /// /// Useful ones are: /// /// > `store` - Returns the store instance. /// > `before` - Optional method that runs before `reduce` during action dispatching. /// > `after` - Optional method that runs after `reduce` during action dispatching. /// > `wrapError` - Optionally catches or modifies errors thrown by `reduce` or `before` methods. /// > `dispatchAndWaitAll` - Dispatches multiple actions in parallel and waits for all to finish. /// > `dispatchAll` - Dispatches multiple actions in parallel. /// > `dispatchSync` - Dispatches a sync action, throws if the action is async. /// > `exceptionFor` - Returns the `UserException` of the action that failed. /// > `clearExceptionFor` - Removes the given action type from the failed actions list. /// > `initialState` - Returns the state as it was when the action was dispatched. This does NOT change. /// > `waitCondition` - Returns a future that completes when the given state condition is true. /// > `waitAllActions` - Returns a future that completes when all given actions finish. /// > `status` - Returns the current status of the action (waiting, failed, completed, etc.). /// > `prop` - Gets a property from the store (timers, streams, etc.). /// > `setProp` - Sets a property in the store. /// > `disposeProp` - Disposes a single property by its key. /// > `disposeProps` - Disposes all or selected properties (timers, streams, futures). /// > `env` - Gets the store environment, useful for global values scoped to the store. /// > `microtask` - Returns a future that completes in the next microtask. /// > `assertUncompletedFuture` - Asserts that an async reducer has at least one await. /// /// Useful mixins: /// /// > `CheckInternet` - Checks if there is internet before running the action, shows dialog if not. /// > `NoDialog` - Used with `CheckInternet` to turn off the dialog when there is no internet. /// > `AbortWhenNoInternet` - Silently aborts the action if there is no internet. /// > `NonReentrant` - Prevents the action from being dispatched if it's already running. /// > `Retry` - Retries the action if it fails, with configurable delays and max retries. /// > `UnlimitedRetries` - Used with `Retry` to retry indefinitely. /// > `OptimisticCommand` - Updates the state optimistically before saving to the cloud. /// > `Throttle` - Ensures the action is dispatched at most once per throttle period. /// > `Debounce` - Delays action execution until after a period of inactivity. /// > `UnlimitedRetryCheckInternet` - Retries indefinitely with internet checking, prevents reentrant dispatches. /// /// Finally, these are one-off methods that you may use in special situations: /// /// > `stateTimestamp` - Returns the timestamp of the last state change. /// > `wrapReduce` - Wraps the `reduce` method for pre/post-processing. /// > `abortDispatch` - Returns true to abort the action dispatch before it runs. /// > `isSync` - Returns true if the action is sync, false if async. /// > `ifWrapReduceOverridden_Sync` - Returns true if `wrapReduce` is overridden synchronously. /// > `ifWrapReduceOverridden_Async` - Returns true if `wrapReduce` is overridden asynchronously. /// > `ifWrapReduceOverridden` - Returns true if `wrapReduce` is overridden (sync or async). /// > `runtimeTypeString` - Returns the `runtimeType` without the generic part. /// abstract class ReduxAction { late Store _store; late St _initialState; ActionStatus _status = ActionStatus(context: null); bool _completedFuture = false; @protected void setStore(Store store) { _store = store; _status = _status.copy(context: (this, store)); _initialState = _store.state; } /// Returns the state as it was when the action was dispatched. /// /// It can be the same or different from `this.state`, which is the current state in the store, /// because other actions may have changed the current state since this action was dispatched. /// /// In the case of SYNC actions that do not dispatch other SYNC actions, /// `this.state` and `this.initialState` will be the same. @protected St get initialState => _initialState; @protected Store get store => _store; ActionStatus get status => _status; /// Gets a property from the store. /// This can be used to save global values, but scoped to the store. /// For example, you could save timers, streams or futures used by actions. /// /// ```dart /// setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); /// var timer = prop("timer"); /// timer.cancel(); /// ``` /// /// See also: [setProp] and [env]. /// @protected V prop(Object? key) => store.prop(key); /// Sets a property in the store. /// This can be used to save global values, but scoped to the store. /// For example, you could save timers, streams or futures used by actions. /// /// ```dart /// setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); /// var timer = prop("timer"); /// timer.cancel(); /// ``` /// /// See also: [prop] and [env]. /// @protected void setProp(Object? key, Object? value) => store.setProp(key, value); /// The [disposeProps] method is used to clean up resources associated with /// the store's properties, by stopping, closing, ignoring and removing timers, /// streams, sinks, and futures that are saved as properties in the store. /// /// In more detail: This method accepts an optional predicate function that /// takes a prop `key` and a `value` as an argument and returns a boolean. /// /// * If you don't provide a predicate function, all properties which are /// `Timer`, `Future`, or `Stream` related will be closed/cancelled/ignored as /// appropriate, and then removed from the props. Other properties will not be /// removed. /// /// * If the predicate function is provided and returns `true` for a given /// property, that property will be removed from the props and, if the property /// is also a `Timer`, `Future`, or `Stream` related, it will be /// closed/cancelled/ignored as appropriate. /// /// * If the predicate function is provided and returns `false` for a given /// property, that property will not be removed from the props, and it will /// not be closed/cancelled/ignored. /// /// This method is particularly useful when the store is being shut down, /// right before or after you called the [Store.shutdown] method. /// /// Example usage: /// /// ```dart /// // Dispose of all Timers, Futures, Stream related etc. /// disposeProps(); /// /// // Dispose only Timers. /// disposeProps(({Object? key, Object? value}) => value is Timer); /// ``` /// /// Note: The provided mixins, like [Throttle] and [Debounce] also use some /// props that you can dispose by doing `store.internalMixinProps.clear()`; /// /// See also: [disposeProp], to dispose a single property by its key. /// @protected void disposeProps([bool Function({Object? key, Object? value})? predicate]) => store.disposeProps(predicate); /// Uses [disposeProps] to dispose and a single property identified by /// its key [keyToDispose], and remove it from the props. /// /// This method will close/cancel/ignore the property if it's a Timer, Future, /// or Stream related object, and then remove it from the props. /// /// Example usage: /// /// ```dart /// // Dispose a specific timer property /// store.disposeProp("myTimer"); /// ``` @protected void disposeProp(Object? keyToDispose) => store.disposeProp(keyToDispose); /// To wait for the next microtask: `await microtask;` @protected Future get microtask => Future.microtask(() {}); @protected St get state => _store.state; DateTime get stateTimestamp => _store.stateTimestamp; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. /// /// ```dart /// store.dispatch(MyAction()); /// ``` /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatch] is of type [Dispatch]. /// /// See also: /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchState] which dispatches a sync action that applies a given reducer to the current state. /// @protected Dispatch get dispatch => _store.dispatch; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// However, if the action is ASYNC, it will throw a [StoreException]. /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatchSync] is of type [DispatchSync]. It returns `ActionStatus`, /// which means you can also get the final status of the action: /// /// ```dart /// var status = store.dispatchSync(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchState] which dispatches a sync action that applies a given reducer to the current state. /// @protected DispatchSync get dispatchSync => _store.dispatchSync; @Deprecated("Use `dispatchAndWait` instead. This will be removed.") @protected DispatchAsync get dispatchAsync => _store.dispatchAndWait; /// This is a shortcut, equivalent to: /// /// ```dart /// var status = dispatchSync( /// UpdateStateAction.withReducer(state), /// ); /// ``` /// /// In other words, it dispatches a sync action that applies the given [state]. /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// This dispatch method is to be used ONLY inside other actions, and is not /// available as an widget extension. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// @protected ActionStatus dispatchState(St state, {bool notify = true}) => dispatchSync(UpdateStateAction(state), notify: notify); /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. In both cases, it returns a [Future] that resolves when /// the action finishes. /// /// ```dart /// await store.dispatchAndWait(DoThisFirstAction()); /// store.dispatch(DoThisSecondAction()); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild /// because of this action, even if it changes the state. /// /// Note: While the state change from the action's reducer will have been applied when /// the Future resolves, other independent processes that the action may have started /// may still be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns `Future`, /// which means you can also get the final status of the action after you `await` it: /// /// ```dart /// var status = await store.dispatchAndWait(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchState] which dispatches a sync action that applies a given reducer to the current state. /// @protected DispatchAndWait get dispatchAndWait => _store.dispatchAndWait; /// Dispatches all given [actions] in parallel, applying their reducers, and possibly changing /// the store state. The actions may be sync or async. It returns a [Future] that resolves when /// ALL actions finish. /// /// ```dart /// var actions = await store.dispatchAndWaitAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// Note this is exactly the same as doing: /// /// ```dart /// var action1 = BuyAction('IBM'); /// var action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2], completeImmediately = true); /// var actions = [action1, action2]; /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of these actions, even if they change the state. /// /// Note: While the state change from the action's reducers will have been applied when the /// Future resolves, other independent processes that the action may have started may still /// be in progress. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchState] which dispatches a sync action that applies a given reducer to the current state. /// @protected Future>> Function(List> actions, {bool notify}) get dispatchAndWaitAll => _store.dispatchAndWaitAll; /// Dispatches all given [actions] in parallel, applying their reducer, and possibly changing /// the store state. It returns the same list of [actions], so that you can instantiate them /// inline, but still get a list of them. /// /// ```dart /// var actions = dispatchAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of these actions, even if it changes the state. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchState] which dispatches a sync action that applies a given reducer to the current state. /// @protected List> Function(List> actions, {bool notify}) get dispatchAll => _store.dispatchAll; /// This is an optional method that may be overridden to run during action /// dispatching, before `reduce`. If this method throws an error, the /// `reduce` method will NOT run, but the method `after` will. /// It may be synchronous (returning `void`) ou async (returning `Future`). /// You should NOT return `FutureOr`. @protected FutureOr before() {} /// This is an optional method that may be overridden to run during action /// dispatching, after `reduce`. If this method throws an error, the /// error will be swallowed (will not throw). So you should only run code that /// can't throw errors. It may be synchronous only. /// Note this method will always be called, /// even if errors were thrown by `before` or `reduce`. /// /// Note: For both synchronous and asynchronous actions, when after runs the store /// already contains the new state returned by reduce, so accessing [state] in [after] /// will return the new state. /// /// Note: Accessing [initialState] in [after] always returns the state as it was when /// the action was dispatched, regardless of when after runs. @protected void after() {} /// The `reduce` method is the action reducer. It may read the action state, /// the store state, and then return a new state (or `null` if no state /// change is necessary). /// /// It may be synchronous (returning `AppState` or `null`) /// or async (returning `Future` or `Future`). /// /// The `StoreConnector`s may rebuild only if the `reduce` method returns /// a state which is both not `null` and different from the previous one /// (comparing by `identical`, not `equals`). @protected FutureOr reduce(); /// You may override [wrapReduce] to wrap the [reduce] method and allow for /// some pre- or post-processing. For example, if you want to prevent an /// async reducer to change the current state in cases where the current /// state has already changed since when the reducer started: /// /// ```dart /// Future wrapReduce(Reducer reduce) async { /// var oldState = state; /// AppState? newState = await reduce(); /// return identical(oldState, state) ? newState : null; /// }; /// ``` /// /// IMPORTANT: /// /// * Your [wrapReduce] method MUST always return `Future`. If it /// returns a `FutureOr`, it will NOT be called, and no error will be shown. /// This is because AsyncRedux uses the return type to determine if /// [wrapReduce] was overridden or not. /// /// * If [wrapReduce] returns `St` or `St?`, an error will be thrown. /// /// * Once you override [wrapReduce] the action will always be ASYNC, /// regardless of the [before] and [reduce] methods. /// /// See mixins [Retry], [Throttle], and [Debounce] for real [wrapReduce] /// examples. /// @protected FutureOr wrapReduce(Reducer reduce) { return null; } /// If any error is thrown by `reduce` or `before`, you have the chance /// to further process it by using `wrapError`. Usually this is used to wrap /// the error inside of another that better describes the failed action. /// For example, if some action converts a String into a number, then instead of /// throwing a FormatException you could do: /// /// ```dart /// wrapError(error, _) => UserException("Please enter a valid number.", cause: error) /// ``` /// /// If you want to disable the error you can return `null`. For example, if you want /// to disable errors of type `MyException`: /// /// ```dart /// wrapError(error, _) => (error is MyException) ? null : error /// ``` /// /// If you don't want to modify the error, just return it unaltered /// (or don't override this method). /// /// See also: /// - [GlobalErrorObserver] which is a global way to wrap errors thrown by actions, /// and is called after this method. /// @protected Object? wrapError(Object error, StackTrace stackTrace) => error; /// If [abortDispatch] returns true, the action will NOT be dispatched: /// `before`, `reduce` and `after` will not be called, and the action will not /// be visible to the store observers. /// /// Note: No observer will be called. It will be as if the action was never /// dispatched. The action status will be `isDispatchAborted: true`. /// /// For example, this mixin prevents reentrant actions (you can only call the /// action if it's not already running): /// /// ```dart /// /// This mixin prevents reentrant actions. You can only call the action if it's not already /// /// running. Example: `class LoadInfo extends ReduxAction with NonReentrant { ... }` /// mixin NonReentrant implements ReduxAction { /// bool abortDispatch() => isWaiting(runtimeType); /// } /// ``` /// /// Using [abortDispatch] is only useful under rare circumstances, and you should /// only use it if you know what you are doing. /// /// See also: /// - [AbortDispatchException] which is a way to abort the action by throwing an exception. /// @protected bool abortDispatch() => false; /// You can use [isWaiting] to check if: /// * A specific async ACTION is currently being processed. /// * An async action of a specific TYPE is currently being processed. /// * If any of a few given async actions or action types is currently being processed. /// /// If you wait for an action TYPE, then it returns false when: /// - The ASYNC action of the type is NOT currently being processed. /// - If the type is not really a type that extends [ReduxAction]. /// - The action of the type is a SYNC action (since those finish immediately). /// /// If you wait for an ACTION, then it returns false when: /// - The ASYNC action is NOT currently being processed. /// - If the action is a SYNC action (since those finish immediately). /// /// Trying to wait for any other type of object will return null and throw /// a [StoreException] after the async gap. /// /// Examples: /// /// ```dart /// // Waiting for an action TYPE: /// dispatch(MyAction()); /// if (store.isWaiting(MyAction)) { // Show a spinner } /// /// // Waiting for an ACTION: /// var action = MyAction(); /// dispatch(action); /// if (store.isWaiting(action)) { // Show a spinner } /// /// // Waiting for any of the given action TYPES: /// dispatch(BuyAction()); /// if (store.isWaiting([BuyAction, SellAction])) { // Show a spinner } /// ``` @protected bool isWaiting(Object actionOrTypeOrList) => _store.isWaiting(actionOrTypeOrList); /// Returns true if an [actionOrTypeOrList] failed with an [UserException]. /// Note: This method uses the EXACT type in [actionOrTypeOrList]. Subtypes are not considered. @protected bool isFailed(Object actionOrTypeOrList) => _store.isFailed(actionOrTypeOrList); /// Returns the [UserException] of the [actionTypeOrList] that failed. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Note: This method uses the EXACT type in [actionTypeOrList]. Subtypes are not considered. @protected UserException? exceptionFor(Object actionTypeOrList) => _store.exceptionFor(actionTypeOrList); /// Removes the given [actionTypeOrList] from the list of action types that failed. /// /// Note that dispatching an action already removes that action type from the exceptions list. /// This removal happens as soon as the action is dispatched, not when it finishes. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Note: This method uses the EXACT type in [actionTypeOrList]. Subtypes are not considered. @protected void clearExceptionFor(Object actionTypeOrList) => _store.clearExceptionFor(actionTypeOrList); /// Returns a future which will complete when the given state [condition] is true. /// If the condition is already true when the method is called, the future completes immediately. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [Store.defaultTimeoutMillis] to change the default timeout. /// /// ```dart /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// ``` @protected Future?> waitCondition( bool Function(St) condition, { int? timeoutMillis, }) => _store.waitCondition(condition, timeoutMillis: timeoutMillis); /// Returns a future that completes when ALL given [actions] finished dispatching. /// You MUST provide at list one action, or an error will be thrown. /// /// If [completeImmediately] is `false` (the default), this method will throw [StoreException] /// if none of the given actions are in progress when the method is called. Otherwise, the future /// will complete immediately and throw no error. /// /// Example: /// /// ```ts /// // Dispatching two actions in PARALLEL and waiting for both to finish. /// var action1 = ChangeNameAction('Bill'); /// var action2 = ChangeAgeAction(42); /// await waitAllActions([action1, action2]); /// /// // Compare this to dispatching the actions in SERIES: /// await dispatchAndWait(action1); /// await dispatchAndWait(action2); /// ``` @protected Future waitAllActions(List> actions, {bool completeImmediately = false}) { if (actions.isEmpty) throw StoreException('You have to provide a non-empty list of actions.'); return _store.waitAllActions(actions, completeImmediately: completeImmediately); } /// An async reducer (one that returns Future) must never complete without at least /// one await, because this may result in state changes being lost. It's up to you to make sure /// all code paths in the reducer pass through at least one `await`. /// /// Futures defined by async functions with no `await` are called "completed futures". /// It's generally easy to make sure an async reducer does not return a completed future. /// In the rare case when your reducer function is complex and you are unsure that all /// code paths pass through an await, there are 3 possible solutions: /// /// /// * Simplify your reducer, by applying clean-code techniques. That will make it easier for you /// to make sure all code paths have 'await'. /// /// * Add `await microtask;` to the very START of the reducer. /// /// * Call method [assertUncompletedFuture] at the very END of your [reduce] method, right before /// the return. If you do that, an error will be shown in the console in case the reduce method /// ever returns a completed future. Note there is no other way for AsyncRedux to warn you if /// your reducer returned a completed future, because although the completion information exists /// in the `FutureImpl` class, it's not exposed. Also note, the error will be thrown /// asynchronously (will not stop the action from returning a state). /// @protected void assertUncompletedFuture() { scheduleMicrotask(() { _completedFuture = true; }); } @protected bool ifWrapReduceOverridden_Sync() => wrapReduce is St? Function(Reducer); @protected bool ifWrapReduceOverridden_Async() => wrapReduce is Future Function(Reducer); @protected bool ifWrapReduceOverridden() => ifWrapReduceOverridden_Async() || ifWrapReduceOverridden_Sync(); /// Returns true if the action is SYNC, and false if the action is ASYNC. /// The action is considered SYNC if the `before` method, the `reduce` method, /// and the `wrapReduce` methods are all synchronous. bool isSync() { // /// Must check that it's NOT `Future Function()`, as `void Function()` doesn't work. bool beforeMethodIsSync = before is! Future Function(); if (!beforeMethodIsSync) return false; bool reduceMethodIsSync = reduce is St? Function(); if (!reduceMethodIsSync) return false; // `wrapReduce` is sync if it's not overridden. // `wrapReduce` is sync if it's overridden and SYNC. // `wrapReduce` is NOT sync if it's overridden and ASYNC. return (!ifWrapReduceOverridden_Async()); } /// Returns the runtimeType, without the generic part. String runtimeTypeString() { var text = runtimeType.toString(); var pos = text.indexOf('<'); return (pos == -1) ? text : text.substring(0, pos); } @override String toString() => 'Action ${runtimeTypeString()}'; } /// If an action throws an [AbortDispatchException] the action will abort immediately /// (But note the `after` method will still be called no mather what). /// The action status will be `isDispatchAborted: true`. /// /// You can use it in the `before` method to abort the action before the `reduce` method /// is called. That's similar to throwing an `UserException`, but without showing any /// errors to the user. /// /// For example, this mixin prevents reentrant actions (you can only call the action if it's not /// already running): /// /// ```dart /// /// This mixin prevents reentrant actions. You can only call the action if it's not already /// /// running. Example: `class LoadInfo extends ReduxAction with NonReentrant { ... }` /// mixin NonReentrant implements ReduxAction { /// bool abortDispatch() => isWaiting(runtimeType); /// } /// ``` /// /// See also: /// - [ReduxAction.abortDispatch] which is a way to abort the action's dispatch. /// class AbortDispatchException implements Exception { @override bool operator ==(Object other) => identical(this, other) || other is AbortDispatchException && runtimeType == other.runtimeType; @override int get hashCode => 0; } /// The [UpdateStateAction] action is used to update the state of the Redux /// store, by applying the given [reducerFunction] to the current state. /// /// Note that inside actions you can directly use [ReduxAction.dispatchState] /// which is a shortcut to dispatch an [UpdateStateAction]. /// class UpdateStateAction extends ReduxAction { // final St? Function(St) reducerFunction; /// When you don't need to use the current state to create the new state, you /// can use the `UpdateStateAction` factory. /// /// Example: /// ``` /// var newState = AppState(...); /// store.dispatch(UpdateStateAction(newState)); /// ``` factory UpdateStateAction(St state) => UpdateStateAction.withReducer((_) => state); /// When you need to use the current state to create the new state, you /// can use `UpdateStateAction.withReducer`. /// /// Example: /// ``` /// store.dispatch(UpdateStateAction.withReducer((state) => state.copy(...))); /// ``` UpdateStateAction.withReducer(this.reducerFunction); @override St? reduce() => reducerFunction(state); } ================================================ FILE: lib/src/show_dialog_super.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; /// Displays a Material dialog above the current contents of the app, with /// Material entrance and exit animations, modal barrier color, and modal /// barrier behavior (dialog is dismissible with a tap on the barrier). /// /// This function takes a [builder] which typically builds a [Dialog] widget. /// Content below the dialog is dimmed with a [ModalBarrier]. The widget /// returned by the [builder] does not share a context with the location that /// [showDialogSuper] is originally called from. Use a [StatefulBuilder] or a /// custom [StatefulWidget] if the dialog needs to update dynamically. /// /// The [child] argument is deprecated, and should be replaced with [builder]. /// /// The [context] argument is used to look up the [Navigator] and [Theme] for /// the dialog. It is only used when the method is called. Its corresponding /// widget can be safely removed from the tree before the dialog is closed. /// /// The [onDismissed] callback will be called when the dialog is dismissed. /// Note: If the dialog is popped by `Navigator.of(context).pop(result)`, /// then the `result` will be available to the callback. That way you can /// differentiate between the dialog being dismissed by an Ok or a Cancel /// button, for example. /// /// The [barrierDismissible] argument is used to indicate whether tapping on the /// barrier will dismiss the dialog. It is `true` by default and can not be `null`. /// /// The [barrierColor] argument is used to specify the color of the modal /// barrier that darkens everything below the dialog. If `null` the default color /// `Colors.black54` is used. /// /// The [useSafeArea] argument is used to indicate if the dialog should only /// display in 'safe' areas of the screen not used by the operating system /// (see [SafeArea] for more details). It is `true` by default, which means /// the dialog will not overlap operating system areas. If it is set to `false` /// the dialog will only be constrained by the screen size. It can not be `null`. /// /// The [useRootNavigator] argument is used to determine whether to push the /// dialog to the [Navigator] furthest from or nearest to the given [context]. /// By default, [useRootNavigator] is `true` and the dialog route created by /// this method is pushed to the root navigator. It can not be `null`. /// /// The [routeSettings] argument is passed to [showGeneralDialog], /// see [RouteSettings] for details. /// /// If the application has multiple [Navigator] objects, it may be necessary to /// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the /// dialog rather than just `Navigator.pop(context, result)`. /// /// Returns a [Future] that resolves to the value (if any) that was passed to /// [Navigator.pop] when the dialog was closed. /// /// ### State Restoration in Dialogs /// /// Using this method will not enable state restoration for the dialog. In order /// to enable state restoration for a dialog, use [Navigator.restorablePush] /// or [Navigator.restorablePushNamed] with [DialogRoute]. /// /// For more information about state restoration, see [RestorationManager]. /// /// {@tool sample --template=freeform} /// /// This sample demonstrates how to create a restorable Material dialog. This is /// accomplished by enabling state restoration by specifying /// [MaterialApp.restorationScopeId] and using [Navigator.restorablePush] to /// push [DialogRoute] when the button is tapped. /// /// {@macro flutter.widgets.RestorationManager} /// /// ```dart imports /// import 'package:flutter/material.dart'; /// ``` /// /// ```dart /// void main() { /// runApp(MyApp()); /// } /// /// class MyApp extends StatelessWidget { /// @override /// Widget build(BuildContext context) { /// return MaterialApp( /// restorationScopeId: 'app', /// title: 'Restorable Routes Demo', /// home: MyHomePage(), /// ); /// } /// } /// /// class MyHomePage extends StatelessWidget { /// static Route _dialogBuilder(BuildContext context, Object? arguments) { /// return DialogRoute( /// context: context, /// builder: (BuildContext context) => const AlertDialog(title: Text('Material Alert!')), /// ); /// } /// /// @override /// Widget build(BuildContext context) { /// return Scaffold( /// body: Center( /// child: OutlinedButton( /// onPressed: () { /// Navigator.of(context).restorablePush(_dialogBuilder); /// }, /// child: const Text('Open Dialog'), /// ), /// ), /// ); /// } /// } /// ``` /// /// {@end-tool} /// /// See also: /// /// * [AlertDialog], for dialogs that have a row of buttons below a body. /// * [SimpleDialog], which handles the scrolling of the contents and does /// not show buttons below its body. /// * [Dialog], on which [SimpleDialog] and [AlertDialog] are based. /// * [showCupertinoDialog], which displays an iOS-style dialog. /// * [showGeneralDialog], which allows for customization of the dialog popup. /// * Future showDialogSuper({ required BuildContext context, required WidgetBuilder builder, bool barrierDismissible = true, Color? barrierColor = Colors.black54, String? barrierLabel, bool useSafeArea = true, bool useRootNavigator = true, RouteSettings? routeSettings, void Function(T?)? onDismissed, }) async { T? result = await showDialog( context: context, builder: builder, barrierDismissible: barrierDismissible, barrierColor: barrierColor, barrierLabel: barrierLabel, useSafeArea: useSafeArea, useRootNavigator: useRootNavigator, routeSettings: routeSettings, ); if (onDismissed != null) onDismissed(result); return result; } /// Displays an iOS-style dialog above the current contents of the app, with /// iOS-style entrance and exit animations, modal barrier color, and modal /// barrier behavior (by default, the dialog is not dismissible with a tap on /// the barrier). /// /// This function takes a [builder] which typically builds a [CupertinoAlertDialog] /// widget. Content below the dialog is dimmed with a [ModalBarrier]. The widget /// returned by the [builder] does not share a context with the location that /// [showCupertinoDialogSuper] is originally called from. Use a [StatefulBuilder] /// or a custom [StatefulWidget] if the dialog needs to update dynamically. /// /// The [context] argument is used to look up the [Navigator] for the dialog. /// It is only used when the method is called. Its corresponding widget can /// be safely removed from the tree before the dialog is closed. /// /// The [onDismissed] callback will be called when the dialog is dismissed. /// Note: If the dialog is popped by `Navigator.of(context).pop(result)`, /// then the `result` will be available to the callback. That way you can /// differentiate between the dialog being dismissed by an Ok or a Cancel /// button, for example. /// /// The [useRootNavigator] argument is used to determine whether to push the /// dialog to the [Navigator] furthest from or nearest to the given `context`. /// By default, `useRootNavigator` is `true` and the dialog route created by /// this method is pushed to the root navigator. /// /// If the application has multiple [Navigator] objects, it may be necessary to /// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the /// dialog rather than just `Navigator.pop(context, result)`. /// /// Returns a [Future] that resolves to the value (if any) that was passed to /// [Navigator.pop] when the dialog was closed. /// /// ### State Restoration in Dialogs /// /// Using this method will not enable state restoration for the dialog. In order /// to enable state restoration for a dialog, use [Navigator.restorablePush] /// or [Navigator.restorablePushNamed] with [CupertinoDialogRoute]. /// /// For more information about state restoration, see [RestorationManager]. /// /// {@tool sample --template=stateless_widget_restoration_cupertino} /// /// This sample demonstrates how to create a restorable Cupertino dialog. This is /// accomplished by enabling state restoration by specifying /// [CupertinoApp.restorationScopeId] and using [Navigator.restorablePush] to /// push [CupertinoDialogRoute] when the [CupertinoButton] is tapped. /// /// {@macro flutter.widgets.RestorationManager} /// /// ```dart /// Widget build(BuildContext context) { /// return CupertinoPageScaffold( /// navigationBar: const CupertinoNavigationBar( /// middle: Text('Home'), /// ), /// child: Center(child: CupertinoButton( /// onPressed: () { /// Navigator.of(context).restorablePush(_dialogBuilder); /// }, /// child: const Text('Open Dialog'), /// )), /// ); /// } /// /// static Route _dialogBuilder(BuildContext context, Object? arguments) { /// return CupertinoDialogRoute( /// context: context, /// builder: (BuildContext context) { /// return const CupertinoAlertDialog( /// title: Text('Title'), /// content: Text('Content'), /// actions: [ /// CupertinoDialogAction(child: Text('Yes')), /// CupertinoDialogAction(child: Text('No')), /// ], /// ); /// }, /// ); /// } /// ``` /// /// {@end-tool} /// /// See also: /// /// * [CupertinoAlertDialog], an iOS-style alert dialog. /// * [showDialog], which displays a Material-style dialog. /// * [showGeneralDialog], which allows for customization of the dialog popup. /// * Future showCupertinoDialogSuper({ required BuildContext context, required WidgetBuilder builder, bool barrierDismissible = true, Color? barrierColor = Colors.black54, String? barrierLabel, bool useSafeArea = true, bool useRootNavigator = true, RouteSettings? routeSettings, void Function(T?)? onDismissed, }) async { T? result = await showCupertinoDialog( context: context, builder: builder, barrierDismissible: barrierDismissible, barrierLabel: barrierLabel, useRootNavigator: useRootNavigator, routeSettings: routeSettings, ); if (onDismissed != null) onDismissed(result); return result; } ================================================ FILE: lib/src/state_observer.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; /// One or more [StateObserver]s can be set during the [Store] creation. Those observers are /// called for all dispatched actions, right after the reducer returns. That happens before the /// `after()` method is called, and before the action's `wrapError()` and the global `wrapError()` /// methods are called. /// /// The parameters are: /// /// * action = The action itself. /// /// * prevState = The state right before the new state returned by the reducer is applied. /// Note this may be different from the state when the reducer was called. /// /// * newState = The state returned by the reducer. Note: If you need to know if the state was /// changed or not by the reducer, you can compare both states: /// `bool ifStateChanged = !identical(prevState, newState);` /// /// * error = Is null if the reducer completed with no error and returned. Otherwise, will be the /// error thrown by the reducer (before any wrapError is applied). Note that, in case of /// error, both prevState and newState will be the current store state when the error was /// thrown. /// /// * dispatchCount = The sequential number of the dispatch. /// ///
/// /// Among other uses, the state-observer is a good place to add METRICS to your application. /// For example: /// /// ``` /// abstract class AppAction extends ReduxAction { /// void trackEvent(AppState prevState, AppState newState) { // Don't to anything } /// } /// /// class AppStateObserver implements StateObserver { /// @override /// void observe( /// ReduxAction action, /// AppState prevState, /// AppState newState, /// Object? error, /// int dispatchCount, /// ) { /// if (action is AppAction) action.trackEvent(prevState, newState, error); /// } /// } /// /// class MyAction extends AppAction { /// @override /// AppState? reduce() { // Do something } /// /// @override /// void trackEvent(AppState prevState, AppState newState, Object? error) => /// MyMetrics().track(this, newState, error); /// } /// /// ``` /// abstract class StateObserver { /// * [action] = The action itself. /// /// * [prevState] = The state right before the new state returned by the reducer is applied. /// Note this may be different from the state when the reducer was called. /// /// * [newState] = The state returned by the reducer. Note: If you need to know if the state was /// changed or not by the reducer, you can compare both states: /// `bool ifStateChanged = !identical(prevState, newState);` /// /// * [error] = Is null if the reducer completed with no error and returned. Otherwise, will be the /// error thrown by the reducer (before any wrapError is applied). Note that, in case of /// error, both prevState and newState will be the current store state when the error /// was thrown. /// /// * [dispatchCount] = The sequential number of the dispatch. /// void observe( ReduxAction action, St prevState, St newState, Object? error, int dispatchCount, ); } ================================================ FILE: lib/src/store.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux library async_redux_store; import 'dart:async'; import 'dart:collection'; import 'package:async_redux/async_redux.dart'; import 'package:async_redux/src/process_persistence.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'connector_tester.dart'; part 'redux_action.dart'; typedef Reducer = FutureOr Function(); typedef Dispatch = FutureOr Function( ReduxAction action, { bool notify, }); typedef DispatchSync = ActionStatus Function( ReduxAction action, { bool notify, }); @Deprecated("Use `DispatchAndWait` instead. This will be removed.") typedef DispatchAsync = Future Function( ReduxAction action, { bool notify, }); typedef DispatchAndWait = Future Function( ReduxAction action, { bool notify, }); /// Creates a Redux store that holds the app state. /// /// The only way to change the state in the store is to dispatch a ReduxAction. /// You may implement these methods: /// /// 1) `AppState reduce()` ➜ /// To run synchronously, just return the state: /// AppState reduce() { ... return state; } /// To run asynchronously, return a future of the state: /// Future reduce() async { ... return state; } /// Note that changing the state is optional. If you return null (or Future of null) /// the state will not be changed. Just the same, if you return the same instance /// of state (or its Future) the state will not be changed. /// /// 2) `FutureOr before()` ➜ Runs before the reduce method. /// If it throws an error, then `reduce` will NOT run. /// To run `before` synchronously, just return void: /// void before() { ... } /// To run asynchronously, return a future of void: /// Future before() async { ... } /// Note: If this method runs asynchronously, then `reduce` will also be async, /// since it must wait for this one to finish. /// /// 3) `void after()` ➜ Runs after `reduce`, even if an error was thrown by /// `before` or `reduce` (akin to a "finally" block). If the `after` method itself /// throws an error, this error will be "swallowed" and ignored. Avoid `after` /// methods which can throw errors. /// /// 4) `bool abortDispatch()` ➜ If this returns true, the action will not be /// dispatched: `before`, `reduce` and `after` will not be called. This is only useful /// under rare circumstances, and you should only use it if you know what you are doing. /// /// 5) `Object? wrapError(error)` ➜ If any error is thrown by `before` or `reduce`, /// you have the chance to further process it by using `wrapError`. Usually this /// is used to wrap the error inside of another that better describes the failed action. /// For example, if some action converts a String into a number, then instead of /// throwing a FormatException you could do: /// `wrapError(error) => UserException("Please enter a valid number.", cause: error)` /// /// --- /// /// • ActionObserver observes the dispatching of actions, /// and may be used to print or log the dispatching of actions. /// /// • StateObservers receive the action, prevState (state right before the new State is /// applied), newState (state that was applied), and are used to track metrics and more. /// /// • GlobalErrorObserver may be used to observe, modify, and swallow action errors /// globally, as well as log them to monitoring services like Sentry and Firebase /// Crashlytics. /// /// For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// class Store { Store({ required St initialState, Object? environment, Object? Function(Store)? dependencies, Object? Function(Store)? configuration, Map props = const {}, bool syncStream = false, TestInfoPrinter? testInfoPrinter, List>? actionObservers, List>? stateObservers, Persistor? persistor, Persistor? cloudSync, ModelObserver? modelObserver, WrapReduce? wrapReduce, GlobalErrorObserver Function(Store)? globalErrorObserver, GlobalWrapError? globalWrapError, ErrorObserver? errorObserver, bool? defaultDistinct, CompareBy? immutableCollectionEquality, int? maxErrorsQueued, }) : _state = initialState, _environment = environment, _props = HashMap()..addAll(props), _stateTimestamp = DateTime.now().toUtc(), _changeController = StreamController.broadcast(sync: syncStream), _actionObservers = actionObservers, _stateObservers = stateObservers, _processPersistence = (persistor == null) ? null // : ProcessPersistence(persistor, initialState), _processCloudSync = (cloudSync == null) ? null // : ProcessPersistence(cloudSync, initialState), _modelObserver = modelObserver, _globalErrorObserver = globalErrorObserver, // // Deprecated (will be removed): Use globalErrorObserver instead. _errorObserver = errorObserver, _globalWrapError = globalWrapError, // _wrapReduce = wrapReduce, _defaultDistinct = defaultDistinct ?? true, _immutableCollectionEquality = immutableCollectionEquality, _errors = Queue(), _maxErrorsQueued = maxErrorsQueued ?? 10, _dispatchCount = 0, _reduceCount = 0, _shutdown = false, _testInfoPrinter = testInfoPrinter, _testInfoController = (testInfoPrinter == null) ? // null : StreamController.broadcast(sync: syncStream) { // Init the config first, so that it can be used by the dependencies. _configuration = configuration?.call(this); // Dependencies can use `store` and `store.configuration`, if necessary. _dependencies = dependencies?.call(this); } St _state; final Object? _environment; Object? _configuration; Object? _dependencies; final Map _props; /// Gets the store environment. /// This can be used to specify if the app is running in production, staging, /// development, test, etc, and to save other global values that are not expected to /// change during the app execution. /// /// If you use this, it's recommended that you make this directly accessible in your /// base action that extends [ReduxAction]. /// /// Note the environment is accessible in widgets through /// [BuildContextExtensionForProviderAndConnector.getEnvironment], so that your widgets /// can respond to different environments, if necessary. /// /// See also: /// - [prop] and [setProp], for saving global values that may change during app execution. /// - [dependencies], for injecting dependencies (like services, repositories, etc). /// - [configuration], for setting configuration (like feature flags, etc). /// Object? get environment => _environment; /// Gets the store dependencies. /// This can be used to create a global value, but scoped to the store, serving /// in practice as dependency injection. /// /// If you use this, it's recommended that you make this directly accessible in your /// base action that extends [ReduxAction]. /// /// Usually, this should not be accessible in widgets, as they should not be aware of /// the dependencies. /// /// See also: /// - [prop] and [setProp], for saving global values that may change during app execution. /// - [env], for specifying if the app is running in production, staging, development, etc. /// - [configuration], for setting configuration (like feature flags, etc). /// Object? get dependencies => _dependencies; /// Gets the store configuration. /// This can be used to create a global value, but scoped to the store, serving /// in practice as configuration injection. For example, you could save feature flags /// in the configuration. /// /// If you use this, it's recommended that you make this directly accessible in your /// base action that extends [ReduxAction]. /// /// See also: /// - [prop] and [setProp], for saving global values that may change during app execution. /// - [dependencies], for injecting dependencies (like services, repositories, etc). /// - [env], for specifying if the app is running in production, staging, development, etc. /// Object? get configuration => _configuration; /// Gets the store properties. @visibleForTesting Map get props => _props; /// Gets a property from the store. /// This can be used to save global values, but scoped to the store. /// For example, you could save timers, streams or futures used by actions. /// /// ```dart /// setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); /// var timer = prop("timer"); /// timer.cancel(); /// ``` /// /// This is also directly accessible in [ReduxAction] and in [VmFactory], as `prop`. /// /// See also: [setProp] and [env]. V prop(Object? key) => _props[key] as V; /// Sets a property in the store. /// This can be used to save global values, but scoped to the store. /// For example, you could save timers, streams or futures used by actions. /// /// ```dart /// setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); /// var timer = prop("timer"); /// timer.cancel(); /// ``` /// /// This is also directly accessible in [ReduxAction] and in [VmFactory], as `prop`. /// /// See also: [prop] and [env]. void setProp(Object? key, Object? value) => _props[key] = value; /// The [disposeProps] method is used to clean up resources associated with /// the store's properties, by stopping, closing, ignoring and removing timers, /// streams, sinks, and futures that are saved as properties in the store. /// /// In more detail: This method accepts an optional predicate function that /// takes a prop `key` and a `value` as an argument and returns a boolean. /// /// * If you don't provide a predicate function, all properties which are /// `Timer`, `Future`, or `Stream` related will be closed/cancelled/ignored as /// appropriate, and then removed from the props. Other properties will not be /// removed. /// /// * If the predicate function is provided and returns `true` for a given /// property, that property will be removed from the props and, if the property /// is also a `Timer`, `Future`, or `Stream` related, it will be /// closed/cancelled/ignored as appropriate. /// /// * If the predicate function is provided and returns `false` for a given /// property, that property will not be removed from the props, and it will /// not be closed/cancelled/ignored. /// /// This method is particularly useful when the store is being shut down, /// right before or after you called the [shutdown] method. /// /// Example usage: /// /// ```dart /// // Dispose of all Timers, Futures, Stream related etc. /// store.disposeProps(); /// /// // Dispose only Timers. /// store.disposeProps(({Object? key, Object? value}) => value is Timer); /// ``` /// /// Note: The provided mixins, like [Throttle] and [Debounce] also use some /// props that you can dispose by doing `store.internalMixinProps.clear()`; /// /// See also: [disposeProp], to dispose a single property by its key. /// void disposeProps([bool Function({Object? key, Object? value})? predicate]) { var keysToRemove = []; for (var MapEntry(key: key, value: value) in _props.entries) { final removeIt = predicate?.call(key: key, value: value) ?? true; if (removeIt) { final ifTimerFutureStream = _closeTimerFutureStream(value); // Removes the key if the predicate was provided and returned true, // or it was not provided but the value is Timer/Future/Stream. if ((predicate != null) || ifTimerFutureStream) keysToRemove.add(key); } } // After the iteration, remove all keys at the same time. keysToRemove.forEach((key) => _props.remove(key)); } /// Uses [disposeProps] to dispose and a single property identified by /// its key [keyToDispose], and remove it from the props. /// /// This method will close/cancel/ignore the property if it's a Timer, /// Future, or Stream related object, and then remove it from the props. /// /// Example usage: /// /// ```dart /// // Dispose a specific timer property /// store.disposeProp("myTimer"); /// ``` void disposeProp(Object? keyToDispose) { disposeProps(({Object? key, Object? value}) => key == keyToDispose); } /// If [obj] is a timer, future or stream related, it will be closed/cancelled/ignored, /// and `true` will be returned. For other object types, the method returns `false`. bool _closeTimerFutureStream(Object? obj) { if (obj is Timer) obj.cancel(); else if (obj is Future) obj.ignore(); else if (obj is StreamSubscription) obj.cancel(); else if (obj is StreamConsumer) obj.close(); else if (obj is Sink) obj.close(); else return false; return true; } DateTime _stateTimestamp; /// The current state of the app. St get state => _state; /// The timestamp of the current state in the store, in UTC. DateTime get stateTimestamp => _stateTimestamp; bool get defaultDistinct => _defaultDistinct; /// 1) If `null` (the default), view-models which are immutable collections will be compared /// by their default equality. /// /// 2) If `CompareBy.byDeepEquals`, view-models which are immutable collections will be compared /// by their items, one by one (potentially slow comparison). /// /// 3) If `CompareBy.byIdentity`, view-models which are immutable collections will be compared /// by their internals being identical (very fast comparison). /// /// Note: This works with immutable collections `IList`, `ISet`, `IMap` and `IMapOfSets` from /// the https://pub.dev/packages/fast_immutable_collections package. /// CompareBy? get immutableCollectionEquality => _immutableCollectionEquality; ModelObserver? get modelObserver => _modelObserver; int get dispatchCount => _dispatchCount; int get reduceCount => _reduceCount; final StreamController _changeController; final List? _actionObservers; final List? _stateObservers; final ProcessPersistence? _processPersistence; final ProcessPersistence? _processCloudSync; final ModelObserver? _modelObserver; final ErrorObserver? _errorObserver; final GlobalWrapError? _globalWrapError; final GlobalErrorObserver Function(Store)? _globalErrorObserver; final WrapReduce? _wrapReduce; final bool _defaultDistinct; final CompareBy? _immutableCollectionEquality; final Queue _errors; /// [UserException]s may be queued to be shown to the user by a /// [UserExceptionDialog] widgets. Usually, if you are not planning on using /// that dialog (or something similar) you should probably not throw /// [UserException]s, so this should not be a problem. Still, to further /// prevent memory problems, there is a maximum number of exceptions the /// queue can hold. final int _maxErrorsQueued; bool _shutdown; // For testing: int _dispatchCount; int _reduceCount; TestInfoPrinter? _testInfoPrinter; StreamController>? _testInfoController; TestInfoPrinter? get testInfoPrinter => _testInfoPrinter; /// A stream that emits the current state when it changes. /// /// # Example /// /// // Create the Store; /// final store = new Store(initialState: 0); /// /// // Listen to the Store's onChange stream, and print the latest /// // state to the console whenever the reducer produces a new state. /// // Store StreamSubscription as a variable, so you can stop listening later. /// final subscription = store.onChange.listen(print); /// /// // Dispatch some actions, which prints the state. /// store.dispatch(IncrementAction()); /// /// // When you want to stop printing, cancel the subscription. /// subscription.cancel(); /// Stream get onChange => _changeController.stream; /// Used by the storeTester. Stream> get onReduce => (_testInfoController != null) ? // _testInfoController!.stream : Stream>.empty(); /// Pause the [Persistor] temporarily. /// /// When [pausePersistor] is called, the Persistor will not start a new persistence process, /// until method [resumePersistor] is called. This will not affect the current persistence /// process, if one is currently running. /// /// Note: A persistence process starts when the [Persistor.persistDifference] method is called, /// and finishes when the future returned by that method completes. /// void pausePersistor() { _processPersistence?.pause(); } /// Pause the [CloudSync] temporarily. /// /// When [pauseCloudSync] is called, the cloud sync will not start a new persistence process, /// until method [resumeCloudSync] is called. This will not affect the current persistence /// process, if one is currently running. /// /// Note: A cloud sync process starts when the [CloudSync.persistDifference] method is called, /// and finishes when the future returned by that method completes. /// void pauseCloudSync() { _processCloudSync?.pause(); } /// Persists the current state (if it's not yet persisted), then pauses the [Persistor] /// temporarily. /// /// When [persistAndPausePersistor] is called, this will not affect the current persistence /// process, if one is currently running. If no persistence process was running, it will /// immediately start a new persistence process (ignoring [Persistor.throttle]). /// /// Then, the Persistor will not start another persistence process, until method /// [resumePersistor] is called. /// /// Note: A persistence process starts when the [Persistor.persistDifference] method is called, /// and finishes when the future returned by that method completes. /// void persistAndPausePersistor() { _processPersistence?.persistAndPause(); } /// Saves the current state (if it's not yet saved) to the cloud, then pauses /// the [CloudSync] temporarily. /// /// When [persistAndPauseCloudSync] is called, this will not affect the current cloud save /// process, if one is currently running. If no cloud save process was running, it will /// immediately start a new save process (ignoring [CloudSync.throttle]). /// /// Then, the CloudSync will not start another cloud save process, until method /// [resumeCloudSync] is called. /// /// Note: A cloud save process starts when the [CloudSync.persistDifference] method is called, /// and finishes when the future returned by that method completes. /// void persistAndPauseCloudSync() { _processCloudSync?.persistAndPause(); } /// Resumes persistence by the [Persistor], /// after calling [pausePersistor] or [persistAndPausePersistor]. void resumePersistor() { _processPersistence?.resume(); } /// Resumes persistence by the [CloudSync], /// after calling [pauseCloudSync] or [persistAndPauseCloudSync]. void resumeCloudSync() { _processCloudSync?.resume(); } /// Asks the [Persistor] to save the [initialState] in the local persistence. Future saveInitialStateInPersistence(St initialState) async => _processPersistence?.saveInitialState(initialState); /// Asks the [CloudSync] to save the [initialState] in the cloud. Future saveInitialStateInCloud(St initialState) async => _processCloudSync?.saveInitialState(initialState); /// Asks the [Persistor] to read the state from the local persistence. /// Important: If you use this, you MUST put this state into the store. /// The Persistor will assume that's the case, and will not work properly otherwise. Future readStateFromPersistence() async => _processPersistence?.readState(); /// Asks the [CloudSync] to read the state from the cloud. /// Important: If you use this, you MUST put this state into the store. /// The CloudSync will assume that's the case, and will not work properly otherwise. Future readStateFromCloudSync() async => _processCloudSync?.readState(); /// Asks the [Persistor] to delete the saved state from the cloud. Future deleteStateFromPersistence() async => _processPersistence?.deleteState(); /// Asks the [CloudSync] to delete the saved state from the cloud. Future deleteStateFromCloud() async => _processCloudSync?.deleteState(); /// Gets, from the [Persistor], the last state that was saved to the local persistence. St? getLastPersistedStateFromPersistor() => _processPersistence?.lastPersistedState; /// Gets, from the [CloudSync], the last state that was saved to the cloud. St? getLastPersistedStateFromCloudSync() => _processCloudSync?.lastPersistedState; /// Turns on testing capabilities, if not already. void initTestInfoController() { _testInfoController ??= StreamController.broadcast(sync: false); } /// Changes the testInfoPrinter. void initTestInfoPrinter(TestInfoPrinter testInfoPrinter) { _testInfoPrinter = testInfoPrinter; initTestInfoController(); } /// Beware: Changes the state directly. Use only for TESTS. /// This will not notify the listeners nor complete wait conditions. void defineState(St state) { _state = state; _stateTimestamp = DateTime.now().toUtc(); } /// The global default timeout for the wait functions like [waitCondition] etc /// is 10 minutes. This value is not final and can be modified. /// To disable the timeout, make it -1. static int defaultTimeoutMillis = 60 * 1000 * 10; /// Returns a future which will complete when the given state [condition] is true. /// /// If [completeImmediately] is `true` (the default) and the condition was already true when /// the method was called, the future will complete immediately and throw no errors. /// /// If [completeImmediately] is `false` and the condition was already true when /// the method was called, it will throw a [StoreException]. /// /// Note: The default here is `true`, while in the other `wait` methods /// like [waitActionCondition] it's `false`. This makes sense because of /// the different use cases for these methods. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [defaultTimeoutMillis] to change the default timeout. /// /// This method is useful in tests, and it returns the action which changed /// the store state into the condition, in case you need it: /// /// ```dart /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// ``` /// /// This method is also eventually useful in production code, in which case you /// should avoid waiting for conditions that may take a very long time to complete, /// as checking the condition is an overhead to every state change. /// /// Examples: /// /// ```ts /// // Dispatches an actions that changes the state, then await for the state change: /// expect(store.state.name, 'John') /// dispatch(ChangeNameAction("Bill")); /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// expect(store.state.name, 'Bill'); /// /// // Dispatches actions and wait until no actions are in progress. /// dispatch(BuyStock('IBM')); /// dispatch(BuyStock('TSLA')); /// await waitAllActions([]); /// expect(state.stocks, ['IBM', 'TSLA']); /// /// // Dispatches two actions in PARALLEL and wait for their TYPES: /// expect(store.state.portfolio, ['TSLA']); /// dispatch(BuyAction('IBM')); /// dispatch(SellAction('TSLA')); /// await store.waitAllActionTypes([BuyAction, SellAction]); /// expect(store.state.portfolio, ['IBM']); /// /// // Dispatches actions in PARALLEL and wait until no actions are in progress. /// dispatch(BuyAction('IBM')); /// dispatch(BuyAction('TSLA')); /// await store.waitAllActions([]); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Dispatches two actions in PARALLEL and wait for them: /// let action1 = BuyAction('IBM'); /// let action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// expect(store.state.portfolio.contains('TSLA'), isFalse); /// /// // Dispatches two actions in SERIES and wait for them: /// await dispatchAndWait(BuyAction('IBM')); /// await dispatchAndWait(SellAction('TSLA')); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Wait until some action of a given type is dispatched. /// dispatch(DoALotOfStuffAction()); /// var action = store.waitActionType(ChangeNameAction); /// expect(action, isA()); /// expect(action.status.isCompleteOk, isTrue); /// expect(store.state.name, 'Bill'); /// /// // Wait until some action of the given types is dispatched. /// dispatch(ProcessStocksAction()); /// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// ``` /// /// See also: /// [waitCondition] - Waits until the state is in a given condition. /// [waitActionCondition] - Waits until the actions in progress meet a given condition. /// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress. /// [waitActionType] - Waits until an action of a given type is NOT in progress. /// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress. /// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching. /// Future?> waitCondition( bool Function(St) condition, { // /// If `completeImmediately` is `true` (the default) and the condition was already true when /// the method was called, the future will complete immediately and throw no errors. /// /// If `completeImmediately` is `false` and the condition was already true when /// the method was called, it will throw a [StoreException]. /// /// Note: The default here is `true`, while in the other `wait` methods /// like [waitActionCondition] it's `false`. This makes sense because of /// the different use cases for these methods. bool completeImmediately = true, // /// The maximum time to wait for the condition to be met. The default is 10 minutes. /// To disable the timeout, make it -1. int? timeoutMillis, }) async { // // If the condition is already true when `waitCondition` is called. if (condition(_state)) { // Complete and return null (no trigger action). if (completeImmediately) return Future.value(null); // else throw an error. else throw StoreException("Awaited state condition was already true, " "and the future completed immediately."); } // else { var completer = Completer?>(); _stateConditionCompleters[condition] = completer; int timeout = timeoutMillis ?? defaultTimeoutMillis; var future = completer.future; if (timeout >= 0) future = completer.future.timeout( Duration(milliseconds: timeout), onTimeout: () { _stateConditionCompleters.remove(condition); throw TimeoutException(null, Duration(milliseconds: timeout)); }, ); return future; } } // This map will hold the completers for each ACTION condition checker function. // 1) The set key is the condition checker function. // 2) The value is the completer, that informs of: // - The set of actions in progress when the condition is met. // - The action that triggered the condition. final _actionConditionCompleters = >, ReduxAction?), Completer<(Set>, ReduxAction?)>>{}; // This map will hold the completers for each STATE condition checker function. // 1) The set key is the condition checker function. // 2) The value is the completer, that informs the action that triggered the condition. final _stateConditionCompleters = ?>>{}; /// Returns a future that completes when some actions meet the given [condition]. /// /// If [completeImmediately] is `false` (the default), this method will throw [StoreException] /// if the condition was already true when the method was called. Otherwise, the future will /// complete immediately and throw no error. /// /// The [condition] is a function that takes the set of actions "in progress", as well as an /// action that just entered the set (by being dispatched) or left the set (by finishing /// dispatching). The function should return `true` when the condition is met, and `false` /// otherwise. For example: /// /// ```dart /// var action = await store.waitActionCondition((actionsInProgress, triggerAction) { ... } /// ``` /// /// You get back an unmodifiable set of the actions being dispatched that met the condition, /// as well as the action that triggered the condition by being added or removed from the set. /// /// Note: The condition is only checked when some action is dispatched or finishes dispatching. /// It's not checked every time action statuses change. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [defaultTimeoutMillis] to change the default timeout. /// /// Examples: /// /// ```ts /// // Dispatches an actions that changes the state, then await for the state change: /// expect(store.state.name, 'John') /// dispatch(ChangeNameAction("Bill")); /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// expect(store.state.name, 'Bill'); /// /// // Dispatches actions and wait until no actions are in progress. /// dispatch(BuyStock('IBM')); /// dispatch(BuyStock('TSLA')); /// await waitAllActions([]); /// expect(state.stocks, ['IBM', 'TSLA']); /// /// // Dispatches two actions in PARALLEL and wait for their TYPES: /// expect(store.state.portfolio, ['TSLA']); /// dispatch(BuyAction('IBM')); /// dispatch(SellAction('TSLA')); /// await store.waitAllActionTypes([BuyAction, SellAction]); /// expect(store.state.portfolio, ['IBM']); /// /// // Dispatches actions in PARALLEL and wait until no actions are in progress. /// dispatch(BuyAction('IBM')); /// dispatch(BuyAction('TSLA')); /// await store.waitAllActions([]); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Dispatches two actions in PARALLEL and wait for them: /// let action1 = BuyAction('IBM'); /// let action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// expect(store.state.portfolio.contains('TSLA'), isFalse); /// /// // Dispatches two actions in SERIES and wait for them: /// await dispatchAndWait(BuyAction('IBM')); /// await dispatchAndWait(SellAction('TSLA')); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Wait until some action of a given type is dispatched. /// dispatch(DoALotOfStuffAction()); /// var action = store.waitActionType(ChangeNameAction); /// expect(action, isA()); /// expect(action.status.isCompleteOk, isTrue); /// expect(store.state.name, 'Bill'); /// /// // Wait until some action of the given types is dispatched. /// dispatch(ProcessStocksAction()); /// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// ``` /// /// See also: /// [waitCondition] - Waits until the state is in a given condition. /// [waitActionCondition] - Waits until the actions in progress meet a given condition. /// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress. /// [waitActionType] - Waits until an action of a given type is NOT in progress. /// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress. /// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching. /// /// You should only use this method in tests. @visibleForTesting Future<(Set>, ReduxAction?)> waitActionCondition( // // /// The condition receives the current actions in progress, and the action that triggered the condition. bool Function(Set> actions, ReduxAction? triggerAction) condition, { // /// If `completeImmediately` is `false` (the default), this method will throw an error if the /// condition is already true when the method is called. Otherwise, the future will complete /// immediately and throw no error. bool completeImmediately = false, // /// Error message in case the condition was already true when the method was called, /// and `completeImmediately` is false. String completedErrorMessage = "Awaited action condition was already true", // /// The maximum time to wait for the condition to be met. The default is 10 minutes. /// To disable the timeout, make it -1. int? timeoutMillis, }) { // // If the condition is already true when `waitActionCondition` is called. if (condition(actionsInProgress(), null)) { // Complete and return the actions in progress and the trigger action. if (completeImmediately) return Future.value((actionsInProgress(), null)); // else throw an error. else throw StoreException( completedErrorMessage + ", and the future completed immediately."); } // else { var completer = Completer<(Set>, ReduxAction?)>(); _actionConditionCompleters[condition] = completer; int timeout = timeoutMillis ?? defaultTimeoutMillis; var future = completer.future; if (timeout >= 0) future = completer.future.timeout( Duration(milliseconds: timeout), onTimeout: () { _actionConditionCompleters.remove(condition); throw TimeoutException(null, Duration(milliseconds: timeout)); }, ); return future; } } /// Returns a future that completes when ALL given [actions] finish dispatching. /// /// If [completeImmediately] is `false` (the default), this method will throw [StoreException] /// if none of the given actions are in progress when the method is called. Otherwise, the future /// will complete immediately and throw no error. /// /// However, if you don't provide any actions (empty list or `null`), the future will complete /// when ALL current actions in progress finish dispatching. In other words, when no actions are /// currently in progress. In this case, if [completeImmediately] is `false`, the method will /// throw an error if no actions are in progress when the method is called. /// /// Note: Waiting until no actions are in progress should only be done in test, never in /// production, as it's very easy to create a deadlock. However, waiting for specific actions to /// finish is safe in production, as long as you're waiting for actions you just dispatched. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [defaultTimeoutMillis] to change the default timeout. /// /// Examples: /// /// ```ts /// // Dispatches an actions that changes the state, then await for the state change: /// expect(store.state.name, 'John') /// dispatch(ChangeNameAction("Bill")); /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// expect(store.state.name, 'Bill'); /// /// // Dispatches actions and wait until no actions are in progress. /// dispatch(BuyStock('IBM')); /// dispatch(BuyStock('TSLA')); /// await waitAllActions([]); /// expect(state.stocks, ['IBM', 'TSLA']); /// /// // Dispatches two actions in PARALLEL and wait for their TYPES: /// expect(store.state.portfolio, ['TSLA']); /// dispatch(BuyAction('IBM')); /// dispatch(SellAction('TSLA')); /// await store.waitAllActionTypes([BuyAction, SellAction]); /// expect(store.state.portfolio, ['IBM']); /// /// // Dispatches actions in PARALLEL and wait until no actions are in progress. /// dispatch(BuyAction('IBM')); /// dispatch(BuyAction('TSLA')); /// await store.waitAllActions([]); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Dispatches two actions in PARALLEL and wait for them: /// let action1 = BuyAction('IBM'); /// let action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// expect(store.state.portfolio.contains('TSLA'), isFalse); /// /// // Dispatches two actions in SERIES and wait for them: /// await dispatchAndWait(BuyAction('IBM')); /// await dispatchAndWait(SellAction('TSLA')); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Wait until some action of a given type is dispatched. /// dispatch(DoALotOfStuffAction()); /// var action = store.waitActionType(ChangeNameAction); /// expect(action, isA()); /// expect(action.status.isCompleteOk, isTrue); /// expect(store.state.name, 'Bill'); /// /// // Wait until some action of the given types is dispatched. /// dispatch(ProcessStocksAction()); /// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// ``` /// /// See also: /// [waitCondition] - Waits until the state is in a given condition. /// [waitActionCondition] - Waits until the actions in progress meet a given condition. /// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress. /// [waitActionType] - Waits until an action of a given type is NOT in progress. /// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress. /// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching. /// Future waitAllActions( List>? actions, { bool completeImmediately = false, int? timeoutMillis, }) { if (actions == null || actions.isEmpty) { return waitActionCondition( completeImmediately: completeImmediately, completedErrorMessage: "No actions were in progress", timeoutMillis: timeoutMillis, (actions, triggerAction) => actions.isEmpty); } else { return waitActionCondition( completeImmediately: completeImmediately, completedErrorMessage: "None of the given actions were in progress", timeoutMillis: timeoutMillis, // (actionsInProgress, triggerAction) { for (var action in actions) { if (actionsInProgress.contains(action)) return false; } return true; }, ); } } /// Returns a future that completes when an action of the given type in NOT in progress /// (it's not being dispatched): /// /// - If NO action of the given type is currently in progress when the method is called, /// and [completeImmediately] is `false` (the default), this method will throw an error. /// /// - If NO action of the given type is currently in progress when the method is called, /// and [completeImmediately] is `true`, the future completes immediately, returns `null`, /// and throws no error. /// /// - If an action of the given type is in progress, the future completes when the action /// finishes, and returns the action. You can use the returned action to check its `status`: /// /// ```dart /// var action = await store.waitActionType(MyAction); /// expect(action.status.originalError, isA()); /// ``` /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [defaultTimeoutMillis] to change the default timeout. /// /// Examples: /// /// ```ts /// // Dispatches an actions that changes the state, then await for the state change: /// expect(store.state.name, 'John') /// dispatch(ChangeNameAction("Bill")); /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// expect(store.state.name, 'Bill'); /// /// // Dispatches actions and wait until no actions are in progress. /// dispatch(BuyStock('IBM')); /// dispatch(BuyStock('TSLA')); /// await waitAllActions([]); /// expect(state.stocks, ['IBM', 'TSLA']); /// /// // Dispatches two actions in PARALLEL and wait for their TYPES: /// expect(store.state.portfolio, ['TSLA']); /// dispatch(BuyAction('IBM')); /// dispatch(SellAction('TSLA')); /// await store.waitAllActionTypes([BuyAction, SellAction]); /// expect(store.state.portfolio, ['IBM']); /// /// // Dispatches actions in PARALLEL and wait until no actions are in progress. /// dispatch(BuyAction('IBM')); /// dispatch(BuyAction('TSLA')); /// await store.waitAllActions([]); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Dispatches two actions in PARALLEL and wait for them: /// let action1 = BuyAction('IBM'); /// let action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// expect(store.state.portfolio.contains('TSLA'), isFalse); /// /// // Dispatches two actions in SERIES and wait for them: /// await dispatchAndWait(BuyAction('IBM')); /// await dispatchAndWait(SellAction('TSLA')); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Wait until some action of a given type is dispatched. /// dispatch(DoALotOfStuffAction()); /// var action = store.waitActionType(ChangeNameAction); /// expect(action, isA()); /// expect(action.status.isCompleteOk, isTrue); /// expect(store.state.name, 'Bill'); /// /// // Wait until some action of the given types is dispatched. /// dispatch(ProcessStocksAction()); /// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// ``` /// /// See also: /// [waitCondition] - Waits until the state is in a given condition. /// [waitActionCondition] - Waits until the actions in progress meet a given condition. /// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress. /// [waitActionType] - Waits until an action of a given type is NOT in progress. /// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress. /// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching. /// /// You should only use this method in tests. @visibleForTesting Future?> waitActionType( Type actionType, { bool completeImmediately = false, int? timeoutMillis, }) async { var (_, triggerAction) = await waitActionCondition( completeImmediately: completeImmediately, completedErrorMessage: "No action of the given type was in progress", timeoutMillis: timeoutMillis, // (actionsInProgress, triggerAction) { return !actionsInProgress.any((action) => action.runtimeType == actionType); }, ); return triggerAction; } /// Returns a future that completes when ALL actions of the given types are NOT in progress /// (none of them are being dispatched): /// /// - If NO action of the given types is currently in progress when the method is called, /// and [completeImmediately] is `false` (the default), this method will throw an error. /// /// - If NO action of the given type is currently in progress when the method is called, /// and [completeImmediately] is `true`, the future completes immediately and throws no error. /// /// - If any action of the given types is in progress, the future completes only when /// no action of the given types is in progress anymore. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [defaultTimeoutMillis] to change the default timeout. /// /// Examples: /// /// ```ts /// // Dispatches an actions that changes the state, then await for the state change: /// expect(store.state.name, 'John') /// dispatch(ChangeNameAction("Bill")); /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// expect(store.state.name, 'Bill'); /// /// // Dispatches actions and wait until no actions are in progress. /// dispatch(BuyStock('IBM')); /// dispatch(BuyStock('TSLA')); /// await waitAllActions([]); /// expect(state.stocks, ['IBM', 'TSLA']); /// /// // Dispatches two actions in PARALLEL and wait for their TYPES: /// expect(store.state.portfolio, ['TSLA']); /// dispatch(BuyAction('IBM')); /// dispatch(SellAction('TSLA')); /// await store.waitAllActionTypes([BuyAction, SellAction]); /// expect(store.state.portfolio, ['IBM']); /// /// // Dispatches actions in PARALLEL and wait until no actions are in progress. /// dispatch(BuyAction('IBM')); /// dispatch(BuyAction('TSLA')); /// await store.waitAllActions([]); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Dispatches two actions in PARALLEL and wait for them: /// let action1 = BuyAction('IBM'); /// let action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// expect(store.state.portfolio.contains('TSLA'), isFalse); /// /// // Dispatches two actions in SERIES and wait for them: /// await dispatchAndWait(BuyAction('IBM')); /// await dispatchAndWait(SellAction('TSLA')); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Wait until some action of a given type is dispatched. /// dispatch(DoALotOfStuffAction()); /// var action = store.waitActionType(ChangeNameAction); /// expect(action, isA()); /// expect(action.status.isCompleteOk, isTrue); /// expect(store.state.name, 'Bill'); /// /// // Wait until some action of the given types is dispatched. /// dispatch(ProcessStocksAction()); /// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// ``` /// /// See also: /// [waitCondition] - Waits until the state is in a given condition. /// [waitActionCondition] - Waits until the actions in progress meet a given condition. /// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress. /// [waitActionType] - Waits until an action of a given type is NOT in progress. /// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress. /// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching. /// /// You should only use this method in tests. @visibleForTesting Future waitAllActionTypes( List actionTypes, { bool completeImmediately = false, int? timeoutMillis, }) async { if (actionTypes.isEmpty) { await waitActionCondition( completeImmediately: completeImmediately, completedErrorMessage: "No actions are in progress", timeoutMillis: timeoutMillis, (actions, triggerAction) => actions.isEmpty, ); } else { await waitActionCondition( completeImmediately: completeImmediately, completedErrorMessage: "No action of the given types was in progress", timeoutMillis: timeoutMillis, // (actionsInProgress, triggerAction) { for (var actionType in actionTypes) { if (actionsInProgress.any((action) => action.runtimeType == actionType)) return false; } return true; }, ); } } /// Returns a future which will complete when ANY action of the given types FINISHES /// dispatching. IMPORTANT: This method is different from the other similar methods, because /// it does NOT complete immediately if no action of the given types is in progress. Instead, /// it waits until an action of the given types finishes dispatching, even if they /// were not yet in progress when the method was called. /// /// This method returns the action that completed the future, which you can use to check /// its `status`. /// /// It's useful when the actions you are waiting for are not yet dispatched when you call this /// method. For example, suppose action `StartAction` starts a process that takes some time /// to run and then dispatches an action called `MyFinalAction`. You can then write: /// /// ```dart /// dispatch(StartAction()); /// var action = await store.waitAnyActionTypeFinishes([MyFinalAction]); /// expect(action.status.originalError, isA()); /// ``` /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [defaultTimeoutMillis] to change the default timeout. /// /// Examples: /// /// ```ts /// // Dispatches an actions that changes the state, then await for the state change: /// expect(store.state.name, 'John') /// dispatch(ChangeNameAction("Bill")); /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// expect(store.state.name, 'Bill'); /// /// // Dispatches actions and wait until no actions are in progress. /// dispatch(BuyStock('IBM')); /// dispatch(BuyStock('TSLA')); /// await waitAllActions([]); /// expect(state.stocks, ['IBM', 'TSLA']); /// /// // Dispatches two actions in PARALLEL and wait for their TYPES: /// expect(store.state.portfolio, ['TSLA']); /// dispatch(BuyAction('IBM')); /// dispatch(SellAction('TSLA')); /// await store.waitAllActionTypes([BuyAction, SellAction]); /// expect(store.state.portfolio, ['IBM']); /// /// // Dispatches actions in PARALLEL and wait until no actions are in progress. /// dispatch(BuyAction('IBM')); /// dispatch(BuyAction('TSLA')); /// await store.waitAllActions([]); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Dispatches two actions in PARALLEL and wait for them: /// let action1 = BuyAction('IBM'); /// let action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// expect(store.state.portfolio.contains('TSLA'), isFalse); /// /// // Dispatches two actions in SERIES and wait for them: /// await dispatchAndWait(BuyAction('IBM')); /// await dispatchAndWait(SellAction('TSLA')); /// expect(store.state.portfolio.containsAll('IBM', 'TSLA'), isFalse); /// /// // Wait until some action of a given type is dispatched. /// dispatch(DoALotOfStuffAction()); /// var action = store.waitActionType(ChangeNameAction); /// expect(action, isA()); /// expect(action.status.isCompleteOk, isTrue); /// expect(store.state.name, 'Bill'); /// /// // Wait until some action of the given types is dispatched. /// dispatch(ProcessStocksAction()); /// var action = store.waitAnyActionTypeFinishes([BuyAction, SellAction]); /// expect(store.state.portfolio.contains('IBM'), isTrue); /// ``` /// /// See also: /// [waitCondition] - Waits until the state is in a given condition. /// [waitActionCondition] - Waits until the actions in progress meet a given condition. /// [waitAllActions] - Waits until the given actions are NOT in progress, or no actions are in progress. /// [waitActionType] - Waits until an action of a given type is NOT in progress. /// [waitAllActionTypes] - Waits until all actions of the given type are NOT in progress. /// [waitAnyActionTypeFinishes] - Waits until ANY action of the given types finish dispatching. /// /// You should only use this method in tests. @visibleForTesting Future> waitAnyActionTypeFinishes( List actionTypes, { int? timeoutMillis, }) async { var (_, triggerAction) = await waitActionCondition( completedErrorMessage: "Assertion error", timeoutMillis: timeoutMillis, // (actionsInProgress, triggerAction) { // // If the triggerAction is one of the actionTypes, if ((triggerAction != null) && actionTypes.contains(triggerAction.runtimeType)) { // If the actions in progress do not contain the triggerAction, then the triggerAction has finished. // Otherwise, the triggerAction has just been dispatched, which is not what we want. bool isFinished = !actionsInProgress.contains(triggerAction); return isFinished; } return false; }, ); // Always non-null, because the condition is only met when an action finishes. return triggerAction!; } /// Adds an error at the end of the error queue. void _addError(UserException error) { if (_errors.length > _maxErrorsQueued) _errors.removeFirst(); _errors.addLast(error); } /// Gets the first error from the error queue, and removes it from the queue. UserException? getAndRemoveFirstError() => // (_errors.isEmpty) // ? null : _errors.removeFirst(); /// Remove an error from the error queue, if it's in the queue. /// Pass it a [source]: /// - A [UserException] object, to remove that error from the queue. /// - An [ActionStatus] object, to remove the error that caused the action to fail. /// - An action ([ReduxAction]), to remove the error that caused the action to fail. /// /// Do nothing if: /// - The error that caused the action to fail is not in the queue. /// - The action did not fail. /// - The status has no [ActionStatus.wrappedError]. /// - The [source] is not a [UserException], [ActionStatus], or [ReduxAction]. /// /// This is sometimes useful in tests. For example: /// /// ```dart /// // Dispatch some action /// var status = await store.dispatchAndWait(SomeAction()); /// /// // Check the action failed as expected /// expect(status.originalError, isError('Insufficient balance.')); /// /// // Make sure there are no more errors /// store.removeError(status); /// expect(store.errors, isEmpty); /// ``` /// /// You can also use the action to remove the error: /// /// ```dart /// // Dispatch some action /// var action = SomeAction(); /// var status = await store.dispatchAndWait(action); /// store.removeError(action); /// ``` /// void removeError(Object source) { Object error; if (source is UserException) error = source; else if (source is ActionStatus && source.wrappedError != null) error = source.wrappedError!; else if (source is ReduxAction && source.status.wrappedError != null) error = source.status.wrappedError!; else return; _errors.removeWhere((queuedError) => queuedError == error); } /// Call this method to shut down the store. /// It won't accept dispatches or change the state anymore. /// /// See also: [isShutdown] and [disposeProps]. void shutdown() { _shutdown = true; internalMixinProps.clear(); } /// Properties used internally by the provided mixins. /// You should not use this directly. final internalMixinProps = _InternalMixinProps(); /// If you are running tests, you can change [forceInternetOnOffSimulation] to /// simulate the internet connection as ON or OFF for the provided mixins /// [CheckInternet], [AbortWhenNoInternet], and [UnlimitedRetryCheckInternet]. /// /// - Return `true` if there IS internet. /// - Return `false` if there is NO internet. /// - Return `null` to use the real internet connection status (default). /// /// Example: /// /// ```dart /// store.forceInternetOnOffSimulation = () => false; /// ``` /// /// This is specially useful during tests, for testing what happens when you /// have no internet connection. And since it's tied to the store, it /// automatically resets when the store is recreated. /// bool? Function() forceInternetOnOffSimulation = () => null; bool get isShutdown => _shutdown; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. /// /// ```dart /// store.dispatch(MyAction()); /// ``` /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatch] is of type [Dispatch]. /// /// See also: /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWaitAllActions] which dispatches an action then waits for all actions to finish. /// FutureOr dispatch(ReduxAction action, {bool notify = true}) => _dispatch(action, notify: notify); /// Dispatches the action, applying its reducer, and possibly changing the store state. /// However, if the action is ASYNC, it will throw a [StoreException]. /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatchSync] is of type [DispatchSync]. It returns `ActionStatus`, /// which means you can also get the final status of the action: /// /// ```dart /// var status = store.dispatchSync(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWaitAllActions] which dispatches an action then waits for all actions to finish. /// ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) { if (!action.isSync()) { throw StoreException( "Can't dispatchSync(${action.runtimeType}) because ${action.runtimeType} is async."); } return _dispatch(action, notify: notify) as ActionStatus; } /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. In both cases, it returns a [Future] that resolves when /// the action finishes. /// /// ```dart /// await store.dispatchAndWait(DoThisFirstAction()); /// store.dispatch(DoThisSecondAction()); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Note: While the state change from the action's reducer will have been applied when the /// Future resolves, other independent processes that the action may have started may still /// be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns `Future`, /// which means you can also get the final status of the action after you `await` it: /// /// ```dart /// var status = await store.dispatchAndWait(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWaitAllActions] which dispatches an action then waits for all actions to finish. /// Future dispatchAndWait(ReduxAction action, {bool notify = true}) => Future.value(_dispatch(action, notify: notify)); /// The [dispatchAndWaitAllActions] should be used in tests only. /// /// It first dispatches the given [action], applying its reducer, and /// possibly changing the store state. The action may be sync or async. /// In both cases, it returns a [Future] that resolves when the action /// finishes. /// /// Then, it waits until ALL current actions in progress finish dispatching. /// In other words, when no other actions are currently in progress. /// /// This dispatch method is meant to be used in tests, not in production, /// as it's very easy to create a deadlock. However, if you do use it in /// production, you may provide a [timeoutMillis], which by default is 10 /// minutes. To disable the timeout, make it -1. This timeout only starts /// counting after the given [action] finished dispatching. Note: If you want, /// you can modify [defaultTimeoutMillis] to change the default timeout. /// /// ```dart /// await store.dispatchAndWaitAllActions(MyAction()); /// ``` /// Future dispatchAndWaitAllActions(ReduxAction action, {bool notify = true, int? timeoutMillis}) async { var actionStatus = await dispatchAndWait(action, notify: notify); await waitAllActions([], completeImmediately: true, timeoutMillis: timeoutMillis); return actionStatus; } /// Dispatches all given [actions] in parallel, applying their reducer, and possibly changing /// the store state. It returns the same list of [actions], so that you can instantiate them /// inline, but still get a list of them. /// /// ```dart /// var actions = dispatchAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of these actions, even if it changes the state. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWaitAllActions] which dispatches an action then waits for all actions to finish. /// List> dispatchAll(List> actions, {bool notify = true}) { for (var action in actions) { dispatch(action, notify: notify); } return actions; } /// Dispatches all given [actions] in parallel, applying their reducers, and possibly changing /// the store state. The actions may be sync or async. It returns a [Future] that resolves when /// ALL actions finish. /// /// ```dart /// var actions = await store.dispatchAndWaitAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// Note this is exactly the same as doing: /// /// ```dart /// var action1 = BuyAction('IBM'); /// var action2 = SellAction('TSLA'); /// dispatch(action1); /// dispatch(action2); /// await store.waitAllActions([action1, action2], completeImmediately = true); /// var actions = [action1, action2]; /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of these actions, even if they change the state. /// /// Note: While the state change from the action's reducers will have been applied when the /// Future resolves, other independent processes that the action may have started may still /// be in progress. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAll] which dispatches all given actions in parallel. /// - [dispatchAndWaitAllActions] which dispatches an action then waits for all actions to finish. /// Future>> dispatchAndWaitAll( List> actions, { bool notify = true, }) async { var futures = >[]; for (var action in actions) { futures.add(dispatchAndWait(action, notify: notify)); } await Future.wait(futures); return actions; } @Deprecated("Use `dispatchAndWait` instead. This will be removed.") Future dispatchAsync(ReduxAction action, {bool notify = true}) => dispatchAndWait(action, notify: notify); FutureOr _dispatch(ReduxAction action, {required bool notify}) { // // The action may access the store/state/dispatch as fields. action.setStore(this); if (_shutdown || action.abortDispatch()) return ActionStatus(isDispatchAborted: true, context: (action, this)); _dispatchCount++; if (action.status.isDispatched) throw new StoreException('The action was already dispatched. ' 'Please, create a new action each time.'); action._status = action._status.copy(isDispatched: true); if (_actionObservers != null) for (ActionObserver observer in _actionObservers) { observer.observe(action, dispatchCount, ini: true); } return _processAction(action, notify: notify); } void createTestInfoSnapshot( St state, ReduxAction action, Object? error, Object? processedError, { required bool ini, }) { if (_testInfoController != null || testInfoPrinter != null) { var reduceInfo = TestInfo( state, ini, action, error, processedError, dispatchCount, reduceCount, errors, ); if (_testInfoController != null) _testInfoController!.add(reduceInfo); if (testInfoPrinter != null) testInfoPrinter!(reduceInfo); } } /// Returns a copy of the error queue, containing user exception errors thrown by /// dispatched actions. Note that this is a copy of the queue, so you can't modify the original /// queue here. Instead, use [getAndRemoveFirstError] to consume the errors, one by one. Queue get errors => Queue.of(_errors); /// We check the return type of methods `before` and `reduce` to decide if the /// reducer is synchronous or asynchronous. It's important to run the reducer /// synchronously, if possible. FutureOr _processAction( ReduxAction action, { bool notify = true, }) { // _calculateIsWaitingIsFailed(action); if (action.isSync()) return _processAction_Sync(action, notify: notify); else return _processAction_Async(action, notify: notify); } void _calculateIsWaitingIsFailed(ReduxAction action) { // // If the action is fallible (that is to say, we have once called `isFailed` for this action), bool fallible = _actionsWeCanCheckFailed.contains(action.runtimeType); bool theUIHasAlreadyUpdated = false; if (fallible) { // Dispatch is starting, so we remove the action from the list of failed actions. var removedAction = _failedActions.remove(action.runtimeType); // Then we notify the UI. Note we don't notify if the action was never checked. if (removedAction != null) { theUIHasAlreadyUpdated = true; _changeController.add(state); } } // Add the action to the list of actions in progress. // Note: We add both SYNC and ASYNC actions. The SYNC actions are important too, // to prevent NonReentrant sync actions, where they call themselves. bool ifWasAdded = _actionsInProgress.add(action); if (ifWasAdded) _checkAllActionConditions(action); // Note: If the UI hasn't updated yet, AND // the action is awaitable (that is to say, we have already called `isWaiting` for this action), if (!theUIHasAlreadyUpdated && _awaitableActions.contains(action.runtimeType)) { _changeController.add(state); } } /// The [triggerAction] is the action that was just added or removed in the list /// of [_actionsInProgress] that triggered the check. /// void _checkAllActionConditions(ReduxAction triggerAction) { List>, ReduxAction?)> keysToRemove = []; _actionConditionCompleters.forEach((condition, completer) { if (condition(actionsInProgress(), triggerAction)) { completer.complete((actionsInProgress(), triggerAction)); keysToRemove.add(condition); } }); keysToRemove.forEach((key) { _actionConditionCompleters.remove(key); }); } /// The [triggerAction] is the action that modified the state to trigger the condition. void _checkAllStateConditions(ReduxAction triggerAction) { List keysToRemove = []; _stateConditionCompleters.forEach((condition, completer) { if (condition(_state)) { completer.complete(triggerAction); keysToRemove.add(condition); } }); keysToRemove.forEach((key) { _stateConditionCompleters.remove(key); }); } /// You can use [isWaiting] and pass it [actionOrTypeOrList] to check if: /// * A specific async ACTION is currently being processed. /// * An async action of a specific TYPE is currently being processed. /// * If any of a few given async actions or action types is currently being processed. /// /// If you wait for an action TYPE, then it returns false when: /// - The ASYNC action of the type is NOT currently being processed. /// - If the type is not really a type that extends [ReduxAction]. /// - The action of the type is a SYNC action (since those finish immediately). /// /// If you wait for an ACTION, then it returns false when: /// - The ASYNC action is NOT currently being processed. /// - If the action is a SYNC action (since those finish immediately). /// /// Trying to wait for any other type of object will return null and throw /// a [StoreException] after the async gap. /// /// Examples: /// /// ```dart /// // Waiting for an action TYPE: /// dispatch(MyAction()); /// if (store.isWaiting(MyAction)) { // Show a spinner } /// /// // Waiting for an ACTION: /// var action = MyAction(); /// dispatch(action); /// if (store.isWaiting(action)) { // Show a spinner } /// /// // Waiting for any of the given action TYPES: /// dispatch(BuyAction()); /// if (store.isWaiting([BuyAction, SellAction])) { // Show a spinner } /// ``` bool isWaiting(Object actionOrTypeOrList) { // // 1) If a type was passed: if (actionOrTypeOrList is Type) { _awaitableActions.add(actionOrTypeOrList); return _actionsInProgress.any((action) => action.runtimeType == actionOrTypeOrList); } // // 2) If an action was passed: else if (actionOrTypeOrList is ReduxAction) { _awaitableActions.add(actionOrTypeOrList.runtimeType); return _actionsInProgress.contains(actionOrTypeOrList); } // // 3) If an iterable was passed: // 3.1) For each action or action type in the iterable... else if (actionOrTypeOrList is Iterable) { bool isWaiting = false; for (var actionOrType in actionOrTypeOrList) { // // 3.2) If it's a type. if (actionOrType is Type) { _awaitableActions.add(actionOrType); // 3.2.1) Is waiting if any of the actions in progress has that exact type. if (!isWaiting) isWaiting = _actionsInProgress.any((action) => action.runtimeType == actionOrType); } // // 3.3) If it's an action. else if (actionOrType is ReduxAction) { _awaitableActions.add(actionOrType.runtimeType); // 3.3.1) Is waiting if any of the actions in progress is the exact action. if (!isWaiting) isWaiting = _actionsInProgress.contains(actionOrType); } // // 3.4) If it's not an action and not an action type, throw an exception. // The exception is thrown after the async gap, so that it doesn't interrupt the processes. else { Future.microtask(() { throw StoreException( "You can't do isWaiting([${actionOrTypeOrList.runtimeType}]). " "Use only actions, action types, or a list of them."); }); } } // 3.5) If the `for` finished without matching any items, return false (it's NOT waiting). return isWaiting; } // 4) If something different was passed, it's an error. We show the error after the // async gap, so we don't interrupt the code. But we return false (not waiting). else { Future.microtask(() { throw StoreException("You can't do isWaiting(${actionOrTypeOrList.runtimeType}), " "Use only actions, action types, or a list of them."); }); return false; } } /// Returns true if an [actionOrTypeOrList] failed with an [UserException]. /// Note: This method uses the EXACT type in [actionOrTypeOrList]. Subtypes are not considered. bool isFailed(Object actionOrTypeOrList) => exceptionFor(actionOrTypeOrList) != null; /// Returns the [UserException] of the [actionTypeOrList] that failed. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Note: This method uses the EXACT type in [actionTypeOrList]. Subtypes are not considered. UserException? exceptionFor(Object actionTypeOrList) { // // 1) If a type was passed: if (actionTypeOrList is Type) { _actionsWeCanCheckFailed.add(actionTypeOrList); var action = _failedActions[actionTypeOrList]; var error = action?.status.wrappedError; return (error is UserException) ? error : null; } // // 2) If a list was passed: else if (actionTypeOrList is Iterable) { for (var actionType in actionTypeOrList) { _actionsWeCanCheckFailed.add(actionType); if (actionType is Type) { var error = _failedActions.entries .firstWhereOrNull((entry) => entry.key == actionType) ?.value .status .wrappedError; return (error is UserException) ? error : null; } else { Future.microtask(() { throw StoreException( "You can't do exceptionFor([${actionTypeOrList.runtimeType}]), " "but only an action Type, or a List of types."); }); } } return null; } // 3) If something different was passed, it's an error. We show the error after the // async gap, so we don't interrupt the code. But we return null. else { Future.microtask(() { throw StoreException( "You can't do exceptionFor(${actionTypeOrList.runtimeType}), " "but only an action Type, or a List of types."); }); return null; } } /// Removes the given [actionTypeOrList] from the list of action types that failed. /// /// Note that dispatching an action already removes that action type from the exceptions list. /// This removal happens as soon as the action is dispatched, not when it finishes. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Note: This method uses the EXACT type in [actionTypeOrList]. Subtypes are not considered. void clearExceptionFor(Object actionTypeOrList) { // // 1) If a type was passed: if (actionTypeOrList is Type) { var result = _failedActions.remove(actionTypeOrList); if (result != null) _changeController.add(state); } // // 2) If a list was passed: else if (actionTypeOrList is Iterable) { Object? result; for (var actionType in actionTypeOrList) { if (actionType is Type) { result = _failedActions.remove(actionType); } else { Future.microtask(() { throw StoreException( "You can't clearExceptionFor([${actionTypeOrList.runtimeType}]), " "but only an action Type, or a List of types."); }); } } if (result != null) _changeController.add(state); } // 3) If something different was passed, it's an error. We show the error after the // async gap, so we don't interrupt the code. But we return null. else { Future.microtask(() { throw StoreException( "You can't clearExceptionFor(${actionTypeOrList.runtimeType}), " "but only an action Type, or a List of types."); }); } } /// We check the return type of methods `before` and `reduce` to decide if the /// reducer is synchronous or asynchronous. It's important to run the reducer /// synchronously, if possible. ActionStatus _processAction_Sync( ReduxAction action, { bool notify = true, }) { // // Creates the "INI" test snapshot. createTestInfoSnapshot(state!, action, null, null, ini: true); // The action may access the store/state/dispatch as fields. assert(action.store == this); var afterWasRun = _Flag(false); Object? originalError, processedError; try { var result = action.before(); if (result is Future) throw StoreException(_beforeTypeErrorMsg); action._status = action._status.copy(hasFinishedMethodBefore: true); if (_shutdown) return action._status; _applyReducer(action, notify: notify); action._status = action._status.copy(hasFinishedMethodReduce: true); if (_shutdown) return action._status; } // catch (error, stackTrace) { originalError = error; processedError = _processError(action, error, stackTrace, afterWasRun); // Error is meant to be "swallowed". if (processedError == null) return action._status; // // Error was not changed. Rethrow. else if (identical(processedError, error)) rethrow; // // Error was wrapped. Throw. else Error.throwWithStackTrace(processedError, stackTrace); } // finally { _finalize(action, originalError, processedError, afterWasRun, notify); } return action._status; } /// We check the return type of methods `before` and `reduce` to decide if the /// reducer is synchronous or asynchronous. It's important to run the reducer /// synchronously, if possible. Future _processAction_Async( ReduxAction action, { bool notify = true, }) async { // // Creates the "INI" test snapshot. createTestInfoSnapshot(state!, action, null, null, ini: true); // The action may access the store/state/dispatch as fields. assert(action.store == this); var afterWasRun = _Flag(false); Object? result, originalError, processedError; try { result = action.before(); if (result is Future) await result; action._status = action._status.copy(hasFinishedMethodBefore: true); if (_shutdown) return action._status; result = _applyReducer(action, notify: notify); if (result is Future) await result; action._status = action._status.copy(hasFinishedMethodReduce: true); if (_shutdown) return action._status; } // catch (error, stackTrace) { originalError = error; processedError = _processError(action, error, stackTrace, afterWasRun); // Error is meant to be "swallowed". if (processedError == null) return action._status; // Error was not changed. Rethrows. else if (identical(processedError, error)) rethrow; // Error was wrapped. Throw. else Error.throwWithStackTrace(processedError, stackTrace); } // finally { _finalize(action, originalError, processedError, afterWasRun, notify); } return action._status; } static const _beforeTypeErrorMsg = "Before should return `void` or `Future`. Do not return `FutureOr`."; static const _reducerTypeErrorMsg = "Reducer should return `St?` or `Future`. "; void _checkReducerType(FutureOr Function() reduce) { // // Sync reducer is acceptable. if (reduce is St? Function()) { return; } // // Async reducer is acceptable. else if (reduce is Future Function()) { return; } // else if (reduce is Future? Function()) { throw StoreException(_reducerTypeErrorMsg + "Do not return `Future?`."); } // else if (reduce is Future? Function()) { throw StoreException(_reducerTypeErrorMsg + "Do not return `Future?`."); } // // ignore: unnecessary_type_check else if (reduce is FutureOr Function()) { throw StoreException(_reducerTypeErrorMsg + "Do not return `FutureOr`."); } // else { throw StoreException( _reducerTypeErrorMsg + "Do not return `${reduce.runtimeType}`."); } } FutureOr _applyReducer(ReduxAction action, {bool notify = true}) { _reduceCount++; // Make sure the action reducer returns an acceptable type. _checkReducerType(action.reduce); if (action.ifWrapReduceOverridden()) { return _applyReduceAndWrapReduce(action, notify: notify); } else { return _applyReduce(action, notify: notify); } } FutureOr _applyReduceAndWrapReduce(ReduxAction action, {bool notify = true}) { // assert(action.ifWrapReduceOverridden()); if (action.ifWrapReduceOverridden_Sync()) throw StoreException("The ${action.runtimeType}.wrapReduce method " "should return `Future`, not `` or ``."); action._completedFuture = false; Reducer _reduce = (_wrapReduce != null) ? _wrapReduce.wrapReduce(action.reduce, this) : action.reduce; return (action.wrapReduce(_reduce) as Future).then((state) { _registerState(state, action, notify: notify); if (action._completedFuture) { Future.error( "The reducer of action ${action.runtimeType} returned a completed Future. " "This may result in state changes being lost. " "Please make sure all code paths in the reducer pass through at least one `await`. " "If necessary, add `await microtask;` to the start of the reducer."); } }); } FutureOr _applyReduce(ReduxAction action, {bool notify = true}) { // Reducer _reduce = (_wrapReduce != null) ? _wrapReduce.wrapReduce(action.reduce, this) : action.reduce; // Sync reducer. if (_reduce is St? Function()) { _registerState(_reduce(), action, notify: notify); } // // Async reducer. else if (_reduce is Future Function()) { /// When a reducer returns a state, we need to apply that state immediately in the store. /// If we wait even a single microtask, another reducer may change the store-state before we /// have the chance to apply the state. This would result in the later reducer overriding the /// value of the other reducer, and state changes will be lost. /// /// To fix this we'll depend on the behavior described below, which was confirmed by the Dart /// team: /// /// 1) When a future returned by an async function completes, it will call the `then` method /// synchronously (in the same microtask), as long as the function returns a value (not a /// Future) AND this happens AFTER at least one await. This means we then have the chance to /// apply the returned state to the store right away. /// /// 2) When a future returned by an async function completes, it will call the `then` method /// asynchronously (delayed to a later microtask) if there was no await in the async /// function. When that happens, the future is created "completed", and Dart will wait for /// the next microtask before calling the `then` method (they do this because they want to /// enforce that a listener on a future is always notified in a later microtask than the one /// where it was registered). This means we will only be able to apply the returned state to /// the store during the next microtask. There is now a chance state will be lost. /// This situation must be avoided at all cost, and it's actually simple to solve it: /// An async reducer must never complete without at least one await. /// Unfortunately, if the developer forgets to add the await, there is no way for AsyncRedux /// to let them know about it, because there is no way for us to know if a Future is /// completed. The completion information exists in the `FutureImpl` class but it's not /// exposed. I have asked the Dart team to expose this information, but they refused. The /// only solution is to document this and trust the developer. /// /// Important: The behavior described above was confirmed by the Dart team, but it's NOT /// documented. In other words, they make no promise that it will be kept in the future. /// If that ever changes, AsyncRedux will need to change too, so that reducers return /// `St? Function(state)` instead of returning `state`. For example, instead of a reducer /// ending with `return state.copy(123)` it would be `return (state) => state.copy(123)`. /// Hopefully, the tests in `sync_async_test.dart` will catch this, if it ever changes. action._completedFuture = false; return _reduce().then((state) { _registerState(state, action, notify: notify); if (action._completedFuture) { Future.error( "The reducer of action ${action.runtimeType} returned a completed Future. " "This may result in state changes being lost. " "Please make sure all code paths in the reducer pass through at least one `await`. " "If necessary, add `await microtask;` to the start of the reducer."); } }); } // // Invalid reducer (FutureOr is not accepted). else { throw StoreException("Reducer should return `St?` or `Future`. " "Do not return `FutureOr`. " "Reduce is of type: '${_reduce.runtimeType}'."); } } /// Adds the state to the changeController, but only if the `reduce` method /// did not return null, and if it did not return the same identical state. /// /// Note: We compare the state using `identical` (which is fast). /// /// The [StateObserver]s are always called (if defined). If you need to know if the state was /// changed or not, you can compare `bool ifStateChanged = identical(prevState, newState)` void _registerState( St? state, ReduxAction action, { bool notify = true, }) { if (_shutdown) return; St prevState = _state; // Reducers may return null state, or the unaltered state, when they don't want to change the // state. Note: If the action is an "active action" it will be removed, so we have to // add the state to _changeController even if it's the same state. if (((state != null) && !identical(_state, state)) || _actionsInProgress.contains(action)) { _state = state ?? _state; _stateTimestamp = DateTime.now().toUtc(); if (notify) { _changeController.add(state ?? _state); } _checkAllStateConditions(action); } St newState = _state; if (_stateObservers != null) for (StateObserver observer in _stateObservers) { observer.observe(action, prevState, newState, null, dispatchCount); } if (_processPersistence != null) _processPersistence.process(action, newState); if (_processCloudSync != null) _processCloudSync.process(action, newState); } /// The actions that are currently being processed. /// Use [isWaiting] to know if an action is currently being processed. final Set> _actionsInProgress = HashSet>.identity(); /// Returns an unmodifiable set of the actions on progress. Set> actionsInProgress() { return new UnmodifiableSetView(_actionsInProgress); } /// Returns a copy of the set of actions on progress. Set> copyActionsInProgress() => HashSet>.identity()..addAll(actionsInProgress()); /// Returns true if the actions in progress are equal to the given set. bool actionsInProgressEqualTo(Set> set) { if (set.length != _actionsInProgress.length) { return false; } return set.containsAll(_actionsInProgress) && _actionsInProgress.containsAll(set); } /// Actions that we may put into [_actionsInProgress]. /// This helps to know when to rebuild to make [isWaiting] work. final Set _awaitableActions = HashSet.identity(); /// The async actions that have failed recently. /// When an action fails by throwing an UserException, it's added to this map (indexed by its /// action type), and removed when it's dispatched. /// Use [isFailed], [exceptionFor] and [clearExceptionFor] to know if you should display /// some error message due to an action failure. /// /// Note: Throwing an UserException can show a modal dialog to the user, and also show the error /// as a message in the UI. If you don't want to show the dialog you can use the `noDialog` /// getter in the error message: `throw UserException('Invalid input').noDialog`. /// final Map> _failedActions = HashMap>(); /// Async actions that we may put into [_failedActions]. /// This helps to know when to rebuild to make [isWaiting] work. final Set _actionsWeCanCheckFailed = HashSet.identity(); /// Returns the processed error. Returns `null` if the error is meant to be "swallowed". Object? _processError( ReduxAction action, Object error, StackTrace stackTrace, _Flag afterWasRun, ) { if (_stateObservers != null) for (StateObserver observer in _stateObservers) { observer.observe(action, _state, _state, error, dispatchCount); } Object? errorOrNull = error; action._status = action._status.copy(originalError: error); try { errorOrNull = action.wrapError(errorOrNull, stackTrace); } catch (_error) { // If the action's wrapError throws an error, it will be used instead // of the original error (but the recommended way is returning the error). errorOrNull = _error; } if (errorOrNull != null) { var globalErrorObserver = _globalErrorObserver?.call(this); if (globalErrorObserver != null) { try { globalErrorObserver._init( error: errorOrNull, originalError: action.status.originalError, stackTrace: stackTrace, action: action, store: this, ); errorOrNull = globalErrorObserver.observe(); } catch (_error) { // If the GlobalErrorObserver throws an error, it will be used instead // of the original error (but the recommended way is returning the error). errorOrNull = _error; } } } // This is DEPRECATED and will be removed in the future. // The recommended way is using a GlobalErrorObserver. if (_globalWrapError != null && errorOrNull != null) { try { errorOrNull = _globalWrapError.wrap(errorOrNull, stackTrace, action); } catch (_error) { // If the GlobalWrapError throws an error, it will be used instead // of the original error (but the recommended way is returning the error). errorOrNull = _error; } } action._status = action._status.copy(wrappedError: errorOrNull); // Memorizes the action that failed. We'll remove it when it's dispatched again. _failedActions[action.runtimeType] = action; afterWasRun.value = true; _after(action); // Memorizes errors of type UserException (in the error queue). // These errors are usually shown to the user in a modal dialog, and are not logged. if (errorOrNull is UserException) { if (errorOrNull.ifOpenDialog) { _addError(errorOrNull); _changeController.add(state); } } else if (errorOrNull is AbortDispatchException) { action._status = action._status.copy(isDispatchAborted: true); } // If an errorObserver was NOT defined, return (to throw) all errors which are // not UserException or AbortDispatchException. if (_errorObserver == null) { if ((errorOrNull is! UserException) && (errorOrNull is! AbortDispatchException)) return errorOrNull; } // If an errorObserver was defined, observe the error. // Then, if the observer returns true, return the error to be thrown. else if (errorOrNull != null) { try { if (_errorObserver.observe(errorOrNull, stackTrace, action, this)) // return errorOrNull; } catch (_error) { // The errorObserver should never throw. However, if it does, print the error. _throws( "Method 'ErrorObserver.observe()' has thrown an error '$_error' " "when observing error '$errorOrNull'.", _error, stackTrace); return errorOrNull; } } return null; } void _finalize( ReduxAction action, Object? error, Object? processedError, _Flag afterWasRun, bool notify, ) { if (!afterWasRun.value) _after(action); bool ifWasRemoved = _actionsInProgress.remove(action); if (ifWasRemoved) _checkAllActionConditions(action); // If we'll not be notifying, it's possible we need to trigger the change controller, when the // action is awaitable (that is to say, when we have already called `isWaiting` for this action). if (_awaitableActions.contains(action.runtimeType) && ((error != null) || !notify)) { _changeController.add(state); } createTestInfoSnapshot(state!, action, error, processedError, ini: false); if (_actionObservers != null) for (ActionObserver observer in _actionObservers) { observer.observe(action, dispatchCount, ini: false); } } void _after(ReduxAction action) { try { action.after(); } catch (error, stackTrace) { // After should never throw. // However, if it does, prints the error information to the console, // then throw the error after an asynchronous gap. _throws( "Method '${action.runtimeType}.after()' " "has thrown an error:\n '$error'.", error, stackTrace, ); } finally { action._status = action._status.copy(hasFinishedMethodAfter: true); } } /// Closes down the store so it will no longer be operational. /// Only use this if you want to destroy the Store while your app is running. /// Do not use this method as a way to stop listening to onChange state changes. /// For that purpose, view the onChange documentation. Future teardown({St? emptyState}) async { if (emptyState != null) _state = emptyState; _stateTimestamp = DateTime.now().toUtc(); return _changeController.close(); } /// Helps testing the `StoreConnector`s methods, such as `onInit`, /// `onDispose` and `onWillChange`. /// /// For example, suppose you have a `StoreConnector` which dispatches /// `SomeAction` on its `onInit`. How could you test that? /// /// ``` /// class MyConnector extends StatelessWidget { /// Widget build(BuildContext context) => StoreConnector( /// vm: () => _Factory(), /// onInit: _onInit, /// builder: (context, vm) { ... } /// } /// /// void _onInit(Store store) => store.dispatch(SomeAction()); /// } /// /// var store = Store(...); /// var connectorTester = store.getConnectorTester(MyConnector()); /// connectorTester.runOnInit(); /// var action = await store.waitAnyActionTypeFinishes([SomeAction]); /// expect(action.someValue, 123); /// ``` /// ConnectorTester getConnectorTester(StatelessWidget widgetConnector) => ConnectorTester(this, widgetConnector); /// Throws the error after an asynchronous gap. void _throws(errorMsg, Object? error, StackTrace stackTrace) { Future(() { Error.throwWithStackTrace( (error == null) ? errorMsg : "$errorMsg:\n $error", stackTrace, ); }); } } enum CompareBy { byDeepEquals, byIdentity } @immutable class ActionStatus { ActionStatus({ this.isDispatched = false, this.hasFinishedMethodBefore = false, this.hasFinishedMethodReduce = false, this.hasFinishedMethodAfter = false, this.isDispatchAborted = false, this.originalError, this.wrappedError, required this.context, }); /// Returns true if the action was already dispatched. An action cannot be dispatched /// more than once, which means that you have to create a new action each time. /// /// Note this may be true even if the action has not yet FINISHED dispatching. /// To check if it has finished, use `action.isFinished`. final bool isDispatched; /// Is true when the `before` method finished executing normally. /// Is false if it has not yet finished executing or if it threw an error. final bool hasFinishedMethodBefore; /// Is true when the `reduce` method finished executing normally, returning a value. /// Is false if it has not yet finished executing or if it threw an error. final bool hasFinishedMethodReduce; /// Is true if the `after` method finished executing. Note the `after` method should /// never throw any errors, but if it does the error will be swallowed and ignored. /// Is false if it has not yet finished executing. final bool hasFinishedMethodAfter; /// Is true if the action was: /// - Aborted with the [ReduxAction.abortDispatch] method, /// - If an [AbortDispatchException] was thrown by the action's `before` or `reduce` /// methods (and survived the `wrapError` and `globalErrorObserver`). Or, /// - If the store was being shut down with the [Store.shutdown] method. final bool isDispatchAborted; /// Holds the error thrown by the action's before/reduce methods, if any. /// This may or may not be equal to the error thrown by the action, because the original error /// will still be processed by the action's `wrapError` and the `globalErrorObserver`. However, /// if `originalError` is non-null, it means the reducer did not finish running. final Object? originalError; /// Holds the error thrown by the action. This may or may not be the same as `originalError`, /// because any errors thrown by the action's before/reduce methods may still be changed /// or cancelled by the action's `wrapError` and the `globalErrorObserver`. This is the /// final error after all these wraps. final Object? wrappedError; /// The action and store related to this status. final (ReduxAction, Store)? context; /// Returns true only if the action has completed, and none of the 'before' or 'reduce' /// methods have thrown an error. This indicates that the 'reduce' method completed and /// returned a result (even if the result was null). The 'after' method also already ran. /// /// This can be useful if you need to dispatch a second method only if the first method /// succeeded: /// /// ```ts /// let action = new LoadInfo(); /// await dispatchAndWait(action); /// if (action.isCompletedOk) dispatch(new ShowInfo()); /// ``` /// /// Or you can also get the state directly from `dispatchAndWait`: /// /// ```ts /// var status = await dispatchAndWait(LoadInfo()); /// if (status.isCompletedOk) dispatch(ShowInfo()); /// ``` bool get isCompletedOk => isCompleted && (originalError == null); /// Returns true only if the action has completed (the 'after' method already ran), but either /// the 'before' or the 'reduce' methods have thrown an error. If this is true, it indicates that /// the reducer could NOT complete, and could not return a value to change the state. bool get isCompletedFailed => isCompleted && (originalError != null); /// Returns true only if the action has completed executing, either with or without errors. /// If this is true, the 'after' method already ran. bool get isCompleted => hasFinishedMethodAfter; ActionStatus copy({ bool? isDispatched, bool? hasFinishedMethodBefore, bool? hasFinishedMethodReduce, bool? hasFinishedMethodAfter, bool? isDispatchAborted, Object? originalError, Object? wrappedError, (ReduxAction, Store)? context, }) => ActionStatus( isDispatched: isDispatched ?? this.isDispatched, hasFinishedMethodBefore: hasFinishedMethodBefore ?? this.hasFinishedMethodBefore, hasFinishedMethodReduce: hasFinishedMethodReduce ?? this.hasFinishedMethodReduce, hasFinishedMethodAfter: hasFinishedMethodAfter ?? this.hasFinishedMethodAfter, isDispatchAborted: isDispatchAborted ?? this.isDispatchAborted, originalError: originalError ?? this.originalError, wrappedError: wrappedError ?? this.wrappedError, context: context ?? this.context, ); @override String toString() => 'ActionStatus{' 'isDispatched: $isDispatched, ' 'hasFinishedMethodBefore: $hasFinishedMethodBefore, ' 'hasFinishedMethodReduce: $hasFinishedMethodReduce, ' 'hasFinishedMethodAfter: $hasFinishedMethodAfter, ' 'isDispatchAborted: $isDispatchAborted, ' 'originalError: $originalError, ' 'wrappedError: $wrappedError' '}'; @override bool operator ==(Object other) => identical(this, other) || other is ActionStatus && runtimeType == other.runtimeType && isDispatched == other.isDispatched && hasFinishedMethodBefore == other.hasFinishedMethodBefore && hasFinishedMethodReduce == other.hasFinishedMethodReduce && hasFinishedMethodAfter == other.hasFinishedMethodAfter && isDispatchAborted == other.isDispatchAborted && originalError == other.originalError && wrappedError == other.wrappedError && context == other.context; @override int get hashCode => Object.hash( isDispatched, hasFinishedMethodBefore, hasFinishedMethodReduce, hasFinishedMethodAfter, isDispatchAborted, originalError, wrappedError, context); } class _Flag { T value; _Flag(this.value); @override bool operator ==(Object other) => true; @override int get hashCode => 0; } typedef OptimisticSyncWithPushRevisionEntry = ({ /// Monotonic counter for *local intents* (dispatches) for this key. /// Incremented once per dispatch of an [OptimisticSyncWithPush] action /// for a given key. Used to decide if a follow-up request may be needed. int localRevision, /// Latest known server revision for this key. /// This is updated by both [OptimisticSyncWithPush] and [ServerPush]. /// It only moves forward (never regresses) to guard against /// out-of-order responses/pushes. Value `-1` means the app doesn't know /// any server revision yet for this key. int serverRevision, /// True if the latest value in memory is from a server push. /// False if it's from a local dispatch. bool isPush, }); /// Some properties used by the Mixins. These are scoped to the store, so they /// reset when the store is recreated, for example during tests. class _InternalMixinProps { final Map throttleLockMap = {}; final Map freshKeyMap = {}; final Map debounceLockMap = {}; final Set nonReentrantKeySet = {}; /// Set for the [OptimisticSync] mixin. Tracks which keys are currently locked. final Set optimisticSyncKeySet = {}; /// Map used by the [OptimisticSyncWithPush] and [ServerPush] mixins. final Map optimisticSyncWithPushRevisionMap = {}; /// Map used by the [Polling] mixin. Stores one-shot timers keyed by action runtimeType. final Map pollingMap = {}; /// Removes the locks for Throttle, Debounce, Fresh, NonReentrant, /// OptimisticSync, OptimisticSyncWithPush, and Polling. void clear() { throttleLockMap.clear(); freshKeyMap.clear(); debounceLockMap.clear(); nonReentrantKeySet.clear(); optimisticSyncKeySet.clear(); optimisticSyncWithPushRevisionMap.clear(); for (final timer in pollingMap.values) timer.cancel(); pollingMap.clear(); } } /// You may subclass [GlobalErrorObserver] and pass it to the store constructor, /// if you want to have a global observer for errors thrown in your actions: /// /// ```dart /// var store = Store( /// initialState: AppState(), /// globalErrorObserver: (store) => AppGlobalErrorObserver(), /// } /// /// class MyGlobalErrorObserver extends GlobalErrorObserver { /// @override /// void wrap() { /// // Do something. /// } /// } /// ``` /// /// Your observer error object will be given all errors thrown in your actions /// (including those of type `UserException`). Then: /// * If it returns the same [error] unaltered, this original error will be used. /// * If it returns something else, that it will be used instead of [error]. /// * If it returns `null`, [error] will be disabled (swallowed). /// /// IMPORTANT: If instead of RETURNING an error you THROW an error inside the `observe` /// method, AsyncRedux will catch this error and use it instead of [error]. /// In other words, returning an error or throwing an error has the same effect. However, /// it is still recommended to return the error rather than throwing it. /// /// Note this observer is called AFTER the action's [ReduxAction.wrapError]. /// /// # Use cases /// /// 1. Use this to set up your app to use 3rd-party services like Sentry or Firebase /// Crashlytics to monitor your app for errors in production, and print them to the /// console in development and testing. Since you are setting it up in a centralized way, /// you don't have to "pollute" your code with logging calls. /// /// 2. Use this to have a global place to convert some exceptions into [UserException]s. /// For example, Firebase may throw some `PlatformException`s in response to a bad /// connection to the server. In this case, you may want to show the user a dialog /// explaining that the connection is bad, which you can do by converting it to /// a [UserException]. Note, this could also be done in the [ReduxAction.wrapError], /// but then you'd have to add it to all actions that use Firebase. /// /// # Parameters you can access in the `observe` method: /// /// - `error`: The error thrown by the action, AFTER `wrapError`. /// - `originalError`: The action error BEFORE `wrapError`. /// - `stackTrace`: The stack trace associated with the error. /// - `action`: The action that triggered the error. /// - `store`: Use it to read `store.environment` or `store.configuration`. /// Do **not** use it to dispatch new actions. /// abstract class GlobalErrorObserver { // /// The error thrown by the action, AFTER being processed by the action's `wrapError`. late final Object error; /// The error thrown by the action, BEFORE being processed by the action's `wrapError`. late final Object originalError; /// The stack trace of the error. late final StackTrace stackTrace; /// The action that threw the error. late final ReduxAction action; /// You can access the store, but do NOT use it to dispatch actions, /// because the store is still processing the current action, and dispatching another /// action may cause unexpected behavior. You can use it to read the environment, /// configuration, and state, if they are relevant to the error you want to return. /// Example: /// /// ```dart /// Environment get environment => store.environment; /// Config get configuration => store.configuration; /// AppState get state => store.state; /// ``` /// late Store store; GlobalErrorObserver(); /// Override this method to return the error you want to be used /// instead of the original error. Or, if you want to keep the original error, /// return it unaltered. If you want to disable the error, return `null`. Object? observe(); void _init({ required Object error, required Object? originalError, required StackTrace stackTrace, required ReduxAction action, required Store store, }) { this.error = error; this.originalError = originalError ?? ''; this.stackTrace = stackTrace; this.action = action; } } /// A dummy global error observer that does nothing (same as not providing it). /// /// See also: [GlobalErrorObserverForDevelopment] and [SwallowGlobalErrorObserver]. /// class GlobalErrorObserverDummy extends GlobalErrorObserver { @override Object? observe() => error; } /// During development you may use this global error observer if you want all errors /// to be shown to the user in a dialog, not only [UserException]s. /// /// In more detail: /// /// - Wraps all errors into [UserException]s, and put them all into the error queue. /// - Errors which are NOT originally [UserException]s will still be thrown. /// /// Use it in the store like this: /// /// ```dart /// var store = Store( /// globalErrorObserver: (store) => GlobalErrorObserverForDevelopment() /// ); /// ``` /// /// See also: [GlobalErrorObserverDummy] and [SwallowGlobalErrorObserver]. /// class GlobalErrorObserverForDevelopment extends GlobalErrorObserver { @override Object? observe() { if (error is! UserException) { Future.microtask(() => store.dispatch( UserExceptionAction(error.toString(), cause: error), )); } return error; } } /// Swallows all errors (not recommended). /// /// Use it in the store like this: /// /// ```dart /// var store = Store( /// globalErrorObserver: (store) => SwallowGlobalErrorObserver() /// ); /// ``` /// /// See also: [GlobalErrorObserverDummy] and [GlobalErrorObserverForDevelopment]. /// class SwallowGlobalErrorObserver extends GlobalErrorObserver { @override Object? observe() => null; } ================================================ FILE: lib/src/store_exception.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// General internal exception for AsyncRedux. class StoreException implements Exception { final String msg; StoreException(this.msg); @override String toString() => msg; @override bool operator ==(Object other) => identical(this, other) || other is StoreException && // runtimeType == other.runtimeType && msg == other.msg; @override int get hashCode => msg.hashCode; } ================================================ FILE: lib/src/store_provider_and_connector.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'dart:collection'; import 'package:async_redux/async_redux.dart'; import 'package:collection/collection.dart' show DeepCollectionEquality; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; /// Convert the entire [Store] into a [Model]. The [Model] will /// be used to build a Widget using the [ViewModelBuilder]. typedef StoreConverter = Model Function(Store store); /// A function that will be run when the [StoreConnector] is initialized (using /// the [State.initState] method). This can be useful for dispatching actions /// that fetch data for your Widget when it is first displayed. typedef OnInitCallback = void Function(Store store); /// A function that will be run when the StoreConnector is removed from the Widget Tree. /// It is run in the [State.dispose] method. /// This can be useful for dispatching actions that remove stale data from your State tree. typedef OnDisposeCallback = void Function(Store store); /// A test of whether or not your `converter` or `vm` function should run in /// response to a State change. For advanced use only. /// Some changes to the State of your application will mean your `converter` /// or `vm` function can't produce a useful Model. In these cases, such as when /// performing exit animations on data that has been removed from your Store, /// it can be best to ignore the State change while your animation completes. /// To ignore a change, provide a function that returns true or false. If the /// returned value is false, the change will be ignored. /// If you ignore a change, and the framework needs to rebuild the Widget, the /// `builder` function will be called with the latest Model produced /// by your `converter` or `vm` functions. typedef ShouldUpdateModel = bool Function(St state); /// A function that will be run on state change, before the build method. /// This function is passed the `Model`, and if `distinct` is `true`, /// it will only be called if the `Model` changes. /// This is useful for making calls to other classes, such as a /// `Navigator` or `TabController`, in response to state changes. /// It can also be used to trigger an action based on the previous state. typedef OnWillChangeCallback = void Function( BuildContext? context, Store store, Model previousVm, Model newVm); /// A function that will be run on State change, after the build method. /// /// This function is passed the `Model`, and if `distinct` is `true`, /// it will only be called if the `Model` changes. /// This can be useful for running certain animations after the build is complete. /// Note: Using a [BuildContext] inside this callback can cause problems if /// the callback performs navigation. For navigation purposes, please use /// an [OnWillChangeCallback]. typedef OnDidChangeCallback = void Function( BuildContext? context, Store store, Model viewModel); /// A function that will be run after the Widget is built the first time. /// This function is passed the store and the initial `Model` created by the [vm] /// or the [converter] function. This can be useful for starting certain animations, /// such as showing Snackbars, after the Widget is built the first time. typedef OnInitialBuildCallback = void Function( BuildContext? context, Store store, Model viewModel); /// Build a Widget using the [BuildContext] and [Model]. /// The [Model] is derived from the [Store] using a [StoreConverter]. typedef ViewModelBuilder = Widget Function( BuildContext context, Model vm, ); /// The aspect function type for selectors. /// Takes the new state value and returns true if the widget should rebuild. typedef SelectorAspect = bool Function(St? value); /// Storage class for selector dependencies. /// Stores all selector aspects for a single dependent widget. class SelectorDependency { /// Flag indicating selectors should be cleared on next registration bool shouldClearSelectors = false; /// Flag tracking if a microtask to clear is scheduled bool shouldClearMutationScheduled = false; /// List of all aspect functions registered by this widget final selectors = >[]; } /// Debug flag to prevent nested select calls. bool _debugIsSelecting = false; // Debug flag to enable logging for `select` mechanism (development only). const bool _debugSelectLogging = false; abstract class StoreConnectorInterface { VmFactory Function()? get vm; StoreConverter? get converter; bool? get distinct; OnInitCallback? get onInit; OnDisposeCallback? get onDispose; bool get rebuildOnChange; ShouldUpdateModel? get shouldUpdateModel; OnWillChangeCallback? get onWillChange; OnDidChangeCallback? get onDidChange; OnInitialBuildCallback? get onInitialBuild; Object? get debug; } /// Build a widget based on the state of the [Store]. /// /// Before the [builder] is run, the [converter] will convert the store into a /// more specific `Model` tailored to the Widget being built. /// /// Every time the store changes, the Widget will be rebuilt. As a performance /// optimization, the Widget can be rebuilt only when the [Model] changes. /// In order for this to work correctly, you must implement [==] and [hashCode] for /// the [Model], and set the [distinct] option to true when creating your StoreConnector. /// /// **IMPORTANT:** /// With the release of [MockBuildContext], the [StoreConnector] is now /// considered deprecated. It will not be marked as deprecated and will not be /// removed, but you should avoid it for new code. /// For new code, prefer [BuildContext] extensions with [MockBuildContext] for /// testing. /// /// The goal of [StoreConnector] was to separate dumb widgets from smart /// widgets and let you test the view model without mounting it. Then you could /// test the dumb widget with simple presentation tests. /// `MockBuildContext` gives you the same benefits, because the dumb widget /// itself, when built with a mock context, works as the view model you can /// inspect and use to call callbacks. /// /// This makes `StoreConnector` unnecessary. `MockBuildContext` is simpler to /// use and avoids extra view model classes and factories. /// class StoreConnector extends StatelessWidget implements StoreConnectorInterface { // /// Build a Widget using the [BuildContext] and [Model]. The [Model] /// is created by the [vm] or [converter] functions. final ViewModelBuilder builder; /// Convert the [Store] into a [Model]. The resulting [Model] will be /// passed to the [builder] function. @override final VmFactory Function()? vm; /// Convert the [Store] into a [Model]. The resulting [Model] will be /// passed to the [builder] function. @override final StoreConverter? converter; /// When [distinct] is true (the default), the Widget is rebuilt only /// when the [Model] changes. In order for this to work correctly, you /// must implement [==] and [hashCode] for the [Model]. @override final bool? distinct; /// A function that will be run when the StoreConnector is initially created. /// It is run in the [State.initState] method. /// This can be useful for dispatching actions that fetch data for your Widget /// when it is first displayed. @override final OnInitCallback? onInit; /// A function that will be run when the StoreConnector is removed from the /// Widget Tree. It is run in the [State.dispose] method. /// This can be useful for dispatching actions that remove stale data from your State tree. @override final OnDisposeCallback? onDispose; /// Determines whether the Widget should be rebuilt when the Store emits an onChange event. @override final bool rebuildOnChange; /// A test of whether or not your [vm] or [converter] function should run in /// response to a State change. For advanced use only. /// Some changes to the State of your application will mean your [vm] or /// [converter] function can't produce a useful Model. In these cases, such as /// when performing exit animations on data that has been removed from your Store, /// it can be best to ignore the State change while your animation completes. /// To ignore a change, provide a function that returns true or false. /// If the returned value is true, the change will be applied. /// If the returned value is false, the change will be ignored. /// If you ignore a change, and the framework needs to rebuild the Widget, /// the [builder] function will be called with the latest [Model] produced /// by your [vm] or [converter] function. @override final ShouldUpdateModel? shouldUpdateModel; /// A function that will be run on State change, before the Widget is built. /// This function is passed the `Model`, and if `distinct` is `true`, /// it will only be called if the `Model` changes. /// This can be useful for imperative calls to things like Navigator, TabController, etc @override final OnWillChangeCallback? onWillChange; /// A function that will be run on State change, after the Widget is built. /// This function is passed the `Model`, and if `distinct` is `true`, /// it will only be called if the `Model` changes. /// This can be useful for running certain animations after the build is complete. /// Note: Using a [BuildContext] inside this callback can cause problems if /// the callback performs navigation. For navigation purposes, please use [onWillChange]. @override final OnDidChangeCallback? onDidChange; /// A function that will be run after the Widget is built the first time. /// This function is passed the store and the initial `Model` created by /// the `vm` or the `converter` function. This can be useful for starting certain /// animations, such as showing snackbars, after the Widget is built the first time. @override final OnInitialBuildCallback? onInitialBuild; /// Pass the parameter `debug: this` to get a more detailed error message. @override final Object? debug; const StoreConnector({ Key? key, required this.builder, this.distinct, this.vm, // Recommended. this.converter, // Can be used instead of `vm`. this.debug, this.onInit, this.onDispose, this.rebuildOnChange = true, this.shouldUpdateModel, this.onWillChange, this.onDidChange, this.onInitialBuild, }) : assert(converter == null || vm == null, "You can't provide both `converter` and `vm`."), assert(converter != null || vm != null, "You should provide the `converter` or the `vm` parameter."), super(key: key); @override Widget build(BuildContext context) { return _StoreStreamListener( store: StoreProvider.backdoorInheritedWidget(context, debug: debug), debug: debug, storeConnector: this, builder: builder, converter: converter, vm: vm, distinct: distinct, onInit: onInit, onDispose: onDispose, rebuildOnChange: rebuildOnChange, shouldUpdateModel: shouldUpdateModel, onWillChange: onWillChange, onDidChange: onDidChange, onInitialBuild: onInitialBuild, ); } /// This is not used directly by the store, but may be used in tests. /// If you have a store and a StoreConnector, and you want its associated /// ViewModel, you can do: /// `Model viewModel = storeConnector.getLatestModel(store);` /// /// And if you want to build the widget: /// `var widget = (storeConnector as dynamic).builder(context, viewModel);` /// Model getLatestModel(Store store) { // // The `vm` parameter is recommended. if (vm != null) { var factory = vm!(); internalsVmFactoryInject(factory, store.state, store); return internalsVmFactoryFromStore(factory) as Model; } // // The `converter` parameter can be used instead of `vm`. else if (converter != null) { return converter!(store as Store); } // else throw AssertionError("View-model can't be created. " "Please provide the vm or the converter parameter."); } } /// Listens to the store and calls builder whenever the store changes. class _StoreStreamListener extends StatefulWidget { final ViewModelBuilder builder; final StoreConverter? converter; final VmFactory Function()? vm; final Store store; final Object? debug; final StoreConnectorInterface storeConnector; final bool rebuildOnChange; final bool? distinct; final OnInitCallback? onInit; final OnDisposeCallback? onDispose; final ShouldUpdateModel? shouldUpdateModel; final OnWillChangeCallback? onWillChange; final OnDidChangeCallback? onDidChange; final OnInitialBuildCallback? onInitialBuild; const _StoreStreamListener({ Key? key, required this.builder, required this.store, required this.debug, required this.converter, required this.vm, required this.storeConnector, this.distinct, this.onInit, this.onDispose, this.rebuildOnChange = true, this.onWillChange, this.onDidChange, this.onInitialBuild, this.shouldUpdateModel, }) : super(key: key); @override State createState() { return _StoreStreamListenerState(); } } /// If the StoreConnector throws an error. class _ConverterError extends Error { final Object? debug; /// The error thrown while running the [StoreConnector.converter] function. final Object error; /// The stacktrace that accompanies the [error] @override final StackTrace stackTrace; /// Creates a ConverterError with the relevant error and stacktrace. _ConverterError(this.error, this.stackTrace, this.debug); @override String toString() { return "Error creating the view model" "${debug == null ? '' : ' (${debug.runtimeType})'}: " "$error\n\n" "$stackTrace\n\n"; } } class _StoreStreamListenerState // extends State<_StoreStreamListener> { Stream? _stream; Model? _latestModel; _ConverterError? _latestError; // If `widget.distinct` was passed, use it. Otherwise, use the store default. bool get _distinct => widget.distinct ?? widget.store.defaultDistinct; /// if [StoreConnector.shouldUpdateModel] returns false, we need to know the /// most recent VALID state (it was valid when [StoreConnector.shouldUpdateModel] /// returned true). We save all valid states into [_mostRecentValidState], and /// when we need to use it we put it into [_forceLastValidStreamState]. St? _mostRecentValidState, _forceLastValidStreamState; @override void initState() { if (widget.onInit != null) { widget.onInit!(widget.store); } _computeLatestModel(); if (widget.shouldUpdateModel != null) { // The initial state has to be valid at this point. // This is needed so that the first stream event // can be compared against a baseline. _mostRecentValidState = widget.store.state; } if ((widget.onInitialBuild != null) && (_latestModel != null)) { WidgetsBinding.instance.addPostFrameCallback((_) { widget.onInitialBuild!( mounted ? context : null, widget.store, _latestModel!, ); }); } _createStream(); super.initState(); } @override void dispose() { if (widget.onDispose != null) { widget.onDispose!(widget.store); } super.dispose(); } @override void didUpdateWidget(_StoreStreamListener oldWidget) { _computeLatestModel(); if (widget.store != oldWidget.store) { _createStream(); } super.didUpdateWidget(oldWidget); } void _computeLatestModel() { try { _latestError = null; _latestModel = getLatestModel(_forceLastValidStreamState ?? widget.store.state); } catch (error, stacktrace) { _latestModel = null; _latestError = _ConverterError(error, stacktrace, widget.debug); } } void _createStream() => _stream = widget.store.onChange // This prevents unnecessary calculations of the view-model. .where(_stateChanged) // Discards invalid states. .where(_shouldUpdateModel) // Calculates the view-model using the `vm` or `converter` functions. .map(_calculateModel) // Don't use `Stream.distinct` because it cannot capture the initial // ViewModel produced by the `converter`. .where(_whereDistinct) // Updates the latest-model with the calculated vm. // Important: This must be done after all other optional // transformations, such as shouldUpdateModel. .transform(StreamTransformer.fromHandlers( handleData: _handleData as void Function(Model?, EventSink)?, handleError: _handleError, )); // This prevents unnecessary calculations of the view-model. bool _stateChanged(St state) { return !identical(_mostRecentValidState, widget.store.state) || _actionsInProgressHaveChanged(); } /// Used by [_actionsInProgressHaveChanged]. Set> _lastActionsInProgress = HashSet>.identity(); /// Returns true if the actions in progress have changed since the last time we checked. bool _actionsInProgressHaveChanged() { if (widget.store.actionsInProgressEqualTo(_lastActionsInProgress)) return false; else { _lastActionsInProgress = widget.store.copyActionsInProgress(); return true; } } // If `shouldUpdateModel` is provided, it will calculate if the STORE state contains // a valid state which may be used to calculate the view-model. If this is not the // case, we revert to the last known valid state, which may be a STORE state or a // STREAM state. Note the view-model is always calculated from the STORE state, // which is always the same or more recent than the STREAM state. We could greatly // simplify all of this if the view-model used the STREAM state. However, this would // mean some small delay in the UI, and there is also the problem that the converter // parameter uses the STORE. bool _shouldUpdateModel(St state) { if (widget.shouldUpdateModel == null) return true; else { _forceLastValidStreamState = null; bool ifStoreHasValidModel = widget.shouldUpdateModel!(widget.store.state); if (ifStoreHasValidModel) { _mostRecentValidState = widget.store.state; return true; } // else { // bool ifStreamHasValidModel = widget.shouldUpdateModel!(state); if (ifStreamHasValidModel) { _mostRecentValidState = state; return false; } else { if (identical(state, widget.store.state)) { _forceLastValidStreamState = _mostRecentValidState; } } } return (_forceLastValidStreamState != null); } } Model? _calculateModel(St state) => getLatestModel(_forceLastValidStreamState ?? widget.store.state); // Don't use `Stream.distinct` since it can't capture the initial vm. bool _whereDistinct(Model? vm) { if (_distinct) { bool isDistinct = _isDistinct(vm); _observeWithTheModelObserver( modelPrevious: _latestModel, modelCurrent: vm, isDistinct: isDistinct, ); return isDistinct; } else return true; } bool _isDistinct(Model? vm) { if ((vm is ImmutableCollection) && (_latestModel is ImmutableCollection) && widget.store.immutableCollectionEquality != null) { if (widget.store.immutableCollectionEquality == CompareBy.byIdentity) return areSameImmutableCollection( vm, _latestModel as ImmutableCollection?); if (widget.store.immutableCollectionEquality == CompareBy.byDeepEquals) { return areImmutableCollectionsWithEqualItems( vm, _latestModel as ImmutableCollection?); } else throw AssertionError(widget.store.immutableCollectionEquality); } else return vm != _latestModel; } void _handleData(Model vm, EventSink sink) { // if (!_distinct) _observeWithTheModelObserver( modelPrevious: _latestModel, modelCurrent: vm, isDistinct: _distinct, ); _latestError = null; if ((widget.onWillChange != null) && (_latestModel != null)) { widget.onWillChange!( mounted ? context : null, widget.store, _latestModel!, vm, ); } _latestModel = vm; if ((widget.onDidChange != null) && (_latestModel != null)) { WidgetsBinding.instance.addPostFrameCallback((_) { widget.onDidChange!( mounted ? context : null, widget.store, _latestModel!, ); }); } sink.add(vm); } // If the view-model construction failed. void _handleError( Object error, StackTrace stackTrace, EventSink sink, ) { _latestModel = null; _latestError = _ConverterError(error, stackTrace, widget.debug); sink.addError(error, stackTrace); } // If there is a ModelObserver, observe. // Note: This observer is only useful for tests. void _observeWithTheModelObserver({ required Model? modelPrevious, required Model? modelCurrent, required bool isDistinct, }) { try { widget.store.modelObserver?.observe( modelPrevious: modelPrevious, modelCurrent: modelCurrent, isDistinct: isDistinct, storeConnector: widget.storeConnector, reduceCount: widget.store.reduceCount, dispatchCount: widget.store.dispatchCount, ); } catch (error, stackTrace) { // The errorObserver should never throw. However, if it does, print the error. _throws("Method 'ModelObserver.observe()' has thrown an error", error, stackTrace); } } /// Throws the error after an asynchronous gap. void _throws(errorMsg, Object? error, StackTrace stackTrace) { Future(() { Error.throwWithStackTrace( (error == null) ? errorMsg : "$errorMsg:\n $error", stackTrace, ); }); } /// The StoreConnector needs the converter or vm parameter (only one of them): /// 1) Converter gets the `store`. /// 2) Vm gets `state` and `dispatch`, so it's easier to use. /// Model getLatestModel(St state) { // // The `vm` parameter is recommended. if (widget.vm != null) { var factory = widget.vm!(); internalsVmFactoryInject(factory, state, widget.store); return internalsVmFactoryFromStore(factory) as Model; } // // The `converter` parameter can be used instead of `vm`. else if (widget.converter != null) { return widget.converter!(widget.store); } // else throw AssertionError("View-model can't be created. " "Please provide vm or converter parameter."); } @override Widget build(BuildContext context) { return widget.rebuildOnChange ? StreamBuilder( stream: _stream, builder: (context, snapshot) => (_latestError != null) ? throw _latestError! : widget.builder(context, _latestModel as Model), ) : _latestError != null ? throw _latestError! : widget.builder(context, _latestModel as Model); } } /// Provides a Redux [Store] to all ancestors of this Widget. /// This should generally be a root widget in your App. /// /// Then, you have two alternatives to access the store: /// /// 1) Connect to the provided store by using a [StoreConnector], and /// the [StoreConnector.vm] parameter: /// /// ```dart /// StoreConnector( /// vm: () => Factory(this), /// builder: (context, vm) => MyHomePage(user: vm.user) /// ); /// ``` /// /// See the documentation for more information on how to create the view-model using the `vm` /// parameter and a `VmFactory` class. /// /// 2) Connect to the provided store by using a [StoreConnector], and /// the [StoreConnector.converter] parameter: /// /// ```dart /// StoreConnector( /// converter: (Store store) => store.state.counter, /// builder: (context, value) => Text('$value', style: const TextStyle(fontSize: 30)), /// ); /// ``` /// See the documentation for more information on how to use the `converter` parameter. /// /// 3) Use the extension methods on [BuildContext], like explained below: /// /// You can read the state of the store using the `context.state` method: /// /// ```dart /// var state = context.state; /// ``` /// /// You can dispatch actions using the [dispatch], [dispatchAll], [dispatchAndWait], /// [dispatchAndWaitAll] and [dispatchSync] methods: /// /// ```dart /// context.dispatch(action); /// context.dispatchAll([action1, action2]); /// context.dispatchAndWait(action); /// context.dispatchAndWaitAll([action1, action2]); /// context.dispatchSync(action); /// ``` /// /// You can also use `context.isWaiting`, `context.isFailed()`, `context.exceptionFor()` /// and `context.clearExceptionFor()`. /// /// IMPORTANT: You need to define this extension in your own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// AppState get state => getState(); /// ``` class StoreProvider extends InheritedWidget { final Store _store; // Explanation // ----------- // // The hierarchy is: // StoreProvider -> _InheritedUntypedDoesNotRebuild -> _WidgetListensOnChange -> _InheritedUntypedRebuilds // // Where: // * StoreProvider is a public, TYPED inherited widget, from where we read // the `state` of type `St`. // // * _InheritedUntypedDoesNotRebuild is an UNTYPED inherited widget used by `dispatch`, // `dispatchAndWait` and `dispatchSync`. That's useful because they can dispatch without // the knowing the St type, but it DOES NOT REBUILD. // // * _WidgetListensOnChange is a StatefulWidget that listens to the store (onChange) and // rebuilds the whenever there is a new state available. // // * _InheritedUntypedRebuilds is an UNTYPED inherited widget that is used by `isWaiting`, // `isFailed` and `exceptionFor`. That's useful because these methods can find it without // the knowing the St type, but it REBUILDS. Note: `_InheritedUntypedRebuilds._isOn` is // true only after `state`, `isWaiting`, `isFailed` and `exceptionFor` are used for the // first time. This is to make it faster by avoiding `updateShouldNotify` before this // inner provider is necessary. StoreProvider({ Key? key, required Store store, required Widget child, }) : _store = _init(store), super( key: key, child: _InheritedUntypedDoesNotRebuild(store: store, child: child), ); /// Provides easy access to the AsyncRedux store state from a BuildContext. /// /// Use this in your widget's build method to read the current store state. /// Any widget that calls this WILL rebuild automatically when the state /// changes (unless you pass the [notify] parameter as `false`). /// /// For convenience, it's recommended that you define this extension in your /// own code: /// ```dart /// extension BuildContextExtension on BuildContext { /// AppState get state => getState(); /// } /// ``` /// /// And then use it like this: /// /// ```dart /// var state = context.state; /// ``` static St state(BuildContext context, {bool notify = true, Object? debug}) { if (notify) { final _InheritedUntypedRebuilds? provider = context .dependOnInheritedWidgetOfExactType<_InheritedUntypedRebuilds>(); if (provider == null) throw throw _exceptionForWrongStoreType( _typeOf<_InheritedUntypedRebuilds>(), debug: debug); St state; try { state = provider._store.state as St; } catch (error) { throw _exceptionForWrongStateType(provider._store.state, St); } // We only turn on rebuilds when this `state` method is used for the first time. // This is to make it faster when this method is not used, which is the // case if the state is only accessed via StoreConnector. _InheritedUntypedRebuilds._isOn = true; return state; } // Get the state without rebuilding when the state later changes. else { return backdoorInheritedWidget(context, debug: debug).state; } } /// This WILL create a dependency, and WILL potentially rebuild the state. /// You don't need `St` to call this method. static Store _getStoreWithDependency_Untyped(BuildContext context, {Object? debug}) { // final _InheritedUntypedRebuilds? provider = context.dependOnInheritedWidgetOfExactType<_InheritedUntypedRebuilds>(); if (provider == null) throw _exceptionForWrongStoreType(_typeOf<_InheritedUntypedRebuilds>(), debug: debug); // We only turn on rebuilds when this `state` method is used for the first // time. This is to make it faster when this method is not used, which is // the case if the state is only accessed via StoreConnector. _InheritedUntypedRebuilds._isOn = true; return provider._store as Store; } /// This WILL NOT create a dependency, and may NOT rebuild the state. /// You don't need `St` to call this method. static Store _getStoreNoDependency_Untyped(BuildContext context, {Object? debug}) { // try { // Try to get the store from the dependency. final element = context.getElementForInheritedWidgetOfExactType< _InheritedUntypedDoesNotRebuild>(); if (element == null) throw _exceptionForWrongStoreType(StoreException, debug: debug); final widget = element.widget as _InheritedUntypedDoesNotRebuild; return widget._store as Store; } // // Try to get the store from the static global backdoor. Only works in // production, since in tests there may be more than one store-provider. catch (error) { try { return backdoorStaticGlobal(); } catch (e) { // Swallow. } // Rethrow the original error when getting the store from the dependency. rethrow; } } /// Workaround to capture generics. static Type _typeOf() => T; /// Dispatch an action with [ReduxAction.dispatch] /// without needing a `StoreConnector`. Example: /// /// ```dart /// StoreProvider.dispatch(context, MyAction()); /// ``` /// /// However, it's recommended that you use the built-in `BuildContext` extension instead: /// /// ```dart /// context.dispatch(action)`. /// ``` static FutureOr dispatch( BuildContext context, ReduxAction action, {Object? debug, bool notify = true}) => _getStoreNoDependency_Untyped(context, debug: debug) .dispatch(action, notify: notify); /// Dispatch an action with [ReduxAction.dispatchSync] /// without needing a `StoreConnector`. Example: /// /// ```dart /// StoreProvider.dispatchSync(context, MyAction()); /// ``` /// /// However, it's recommended that you use the built-in `BuildContext` extension instead: /// /// ```dart /// context.dispatchSync(action)`. /// ``` static ActionStatus dispatchSync( BuildContext context, ReduxAction action, {Object? debug, bool notify = true}) => _getStoreNoDependency_Untyped(context, debug: debug) .dispatchSync(action, notify: notify); /// Dispatch an action with [ReduxAction.dispatchAndWait] /// without needing a `StoreConnector`. Example: /// /// ```dart /// var status = await StoreProvider.dispatchAndWait(context, MyAction()); /// ``` /// /// However, it's recommended that you use the built-in `BuildContext` extension instead: /// /// ```dart /// var status = await context.dispatchAndWait(action)`. /// ``` static Future dispatchAndWait( BuildContext context, ReduxAction action, {Object? debug, bool notify = true}) => _getStoreNoDependency_Untyped(context, debug: debug) .dispatchAndWait(action, notify: notify); /// Dispatch a list of actions with [ReduxAction.dispatchAll] /// without needing a `StoreConnector`. Example: /// /// ```dart /// StoreProvider.dispatchAll(context, [Action1(), Action2()]); /// ``` /// /// However, it's recommended that you use the built-in `BuildContext` extension instead: /// /// ```dart /// context.dispatchAll([Action1(), Action2()])`. /// ``` static List> dispatchAll( BuildContext context, List> actions, { Object? debug, bool notify = true, }) => _getStoreNoDependency_Untyped(context, debug: debug) .dispatchAll(actions, notify: notify); /// Dispatch a list of actions with [ReduxAction.dispatchAndWaitAll] /// without needing a `StoreConnector`. Example: /// /// ```dart /// var status = await StoreProvider.dispatchAndWaitAll(context, [Action1(), Action2()]); /// ``` /// /// However, it's recommended that you use the built-in `BuildContext` extension instead: /// /// ```dart /// var status = await context.dispatchAndWaitAll([Action1(), Action2()])`. /// ``` static Future>> dispatchAndWaitAll( BuildContext context, List> actions, { Object? debug, bool notify = true, }) => _getStoreNoDependency_Untyped(context, debug: debug) .dispatchAndWaitAll(actions, notify: notify); /// Returns a future which will complete when the given state [condition] is true. /// If the condition is already true when the method is called, the future completes immediately. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [Store.defaultTimeoutMillis] to change the default timeout. /// /// ```dart /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// ``` static Future?> waitCondition( BuildContext context, bool Function(St) condition, { int? timeoutMillis, }) => backdoorInheritedWidget(context) .waitCondition(condition, timeoutMillis: timeoutMillis); /// Returns a future that completes when ALL given [actions] finished dispatching. /// /// Example: /// /// ```ts /// // Dispatching two actions in PARALLEL and waiting for both to finish. /// var action1 = ChangeNameAction('Bill'); /// var action2 = ChangeAgeAction(42); /// await waitAllActions([action1, action2]); /// /// // Compare this to dispatching the actions in SERIES: /// await dispatchAndWait(action1); /// await dispatchAndWait(action2); /// ``` static Future waitAllActions( BuildContext context, List> actions) { if (actions.isEmpty) throw StoreException('You have to provide a non-empty list of actions.'); return backdoorInheritedWidget(context).waitAllActions(actions); } /// You can use [isWaiting] and pass it [actionOrTypeOrList] to check if: /// * A specific async ACTION is currently being processed. /// * An async action of a specific TYPE is currently being processed. /// * If any of a few given async actions or action types is currently being processed. /// /// If you wait for an action TYPE, then it returns false when: /// - The ASYNC action of the type is NOT currently being processed. /// - If the type is not really a type that extends [ReduxAction]. /// - The action of the type is a SYNC action (since those finish immediately). /// /// If you wait for an ACTION, then it returns false when: /// - The ASYNC action is NOT currently being processed. /// - If the action is a SYNC action (since those finish immediately). /// /// Trying to wait for any other type of object will return null and throw /// a [StoreException] after the async gap. /// /// Widgets that use this method WILL rebuild whenever the state changes /// (unless you pass the [notify] parameter as `false`). /// static bool isWaiting( BuildContext context, Object actionOrTypeOrList, { bool notify = true, }) => (notify ? _getStoreWithDependency_Untyped : _getStoreNoDependency_Untyped)(context) .isWaiting(actionOrTypeOrList); /// Returns true if an [actionOrTypeOrList] failed with an [UserException]. /// /// It's recommended that you use the BuildContext extension instead: /// /// ```dart /// if (context.isFailed(MyAction)) { // Show an error message. } /// ``` /// /// Widgets that use this method WILL rebuild whenever the state changes /// (unless you pass the [notify] parameter as `false`). /// static bool isFailed( BuildContext context, Object actionOrTypeOrList, { bool notify = true, }) => (notify ? _getStoreWithDependency_Untyped : _getStoreNoDependency_Untyped)(context) .isFailed(actionOrTypeOrList); /// Returns the [UserException] of the [actionTypeOrList] that failed. /// /// The [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// It's recommended that you use the BuildContext extension instead: /// /// ```dart /// if (context.isFailed(SaveUserAction)) Text(context.exceptionFor(SaveUserAction)!.reason ?? ''); /// ``` /// /// Widgets that use this method WILL rebuild whenever the state changes /// (unless you pass the [notify] parameter as `false`). /// static UserException? exceptionFor( BuildContext context, Object actionOrTypeOrList, { bool notify = true, }) => (notify ? _getStoreWithDependency_Untyped : _getStoreNoDependency_Untyped)(context) .exceptionFor(actionOrTypeOrList); /// Removes the given [actionTypeOrList] from the list of action types that failed. /// /// Note that dispatching an action already removes that action type from the exceptions list. /// This removal happens as soon as the action is dispatched, not when it finishes. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Widgets that use this method WILL rebuild whenever the state changes /// (unless you pass the [notify] parameter as `false`). /// static void clearExceptionFor( BuildContext context, Object actionOrTypeOrList, { bool notify = true, }) => (notify ? _getStoreWithDependency_Untyped : _getStoreNoDependency_Untyped)(context) .clearExceptionFor(actionOrTypeOrList); /// Avoid using if you don't have a good reason to do so. /// /// The [backdoorInheritedWidget] gives you direct access to the store for advanced /// use-cases. It does NOT create a dependency like [_getStoreWithDependency_Untyped] does, /// and it does NOT rebuild the state when the state changes, when you access it like this: /// `var state = StoreProvider.backdoorInheritedWidget(context, this).state;`. /// static Store backdoorInheritedWidget(BuildContext context, {Object? debug}) { // final element = context.getElementForInheritedWidgetOfExactType>(); final StoreProvider? provider = element?.widget as StoreProvider?; if (provider == null) throw _exceptionForWrongStoreType(_typeOf>(), debug: debug); return provider._store; } /// Avoid using this if you don't have a good reason to do so. /// /// The [backdoorStaticGlobal] gives you direct access to the store for /// advanced use-cases. It does NOT need the context, as it gets the store /// from the static field [_staticStoreBackdoor]. /// /// Note this field is set when the [StoreProvider] is created, which assumes /// the [StoreProvider] is used only once in your app. This is usually a /// reasonable assumption in production, but can break in tests. /// /// It is similar to [_getStoreNoDependency_Untyped] in that is does not /// create a dependency, but it does not need the context, which means /// you can use it anywhere, even outside of the widget tree. /// /// Use it like this: /// /// ```dart /// var state = StoreProvider.backdoorStaticGlobal().state;`. /// ``` /// static Store backdoorStaticGlobal() { if (_staticStoreBackdoor == null) throw StoreException('Error: No Redux store found. ' 'Did you forget to use the StoreProvider?'); if (_staticStoreBackdoor is! Store) { var type = _typeOf>; throw StoreException( 'Error: Store is of type ${_staticStoreBackdoor.runtimeType} ' 'and not of type $type. Please provide the correct type.'); } return _staticStoreBackdoor as Store; } static Store backdoorStaticGlobalUntyped() { if (_staticStoreBackdoor == null) throw StoreException('Error: No Redux store found. ' 'Did you forget to use the StoreProvider?'); return _staticStoreBackdoor!; } /// See [backdoorStaticGlobal]. static Store? _staticStoreBackdoor; static Store _init(Store store) { _staticStoreBackdoor = store; return store; } @override bool updateShouldNotify(StoreProvider oldWidget) { // Only notify dependents if the store instance changes, // not on every state change within the store. return _store != oldWidget._store; } } /// An UNTYPED inherited widget used by `dispatch`, `dispatchAndWait` and /// `dispatchSync`. That's useful because they can dispatch without the knowing /// the St type, but it DOES NOT REBUILD. class _InheritedUntypedDoesNotRebuild extends InheritedWidget { final Store _store; _InheritedUntypedDoesNotRebuild({ Key? key, required Store store, required Widget child, }) : _store = store, super( key: key, child: _WidgetListensOnChange(store: store, child: child), ); @override bool updateShouldNotify(_InheritedUntypedDoesNotRebuild oldWidget) { // Only notify dependents if the store instance changes, // not on every state change within the store. return _store != oldWidget._store; } } /// A StatefulWidget that listens to the store (onChange) and /// rebuilds the whenever there is a new state available. class _WidgetListensOnChange extends StatefulWidget { final Widget child; final Store store; _WidgetListensOnChange({required this.store, required this.child}); @override _WidgetListensOnChangeState createState() => _WidgetListensOnChangeState(); } class _WidgetListensOnChangeState extends State<_WidgetListensOnChange> { @override void initState() { super.initState(); widget.store.onChange.listen((state) { if (mounted) { setState(() {}); } }); } @override Widget build(BuildContext context) { // The Inner InheritedWidget is rebuilt whenever the store's state changes, // triggering rebuilds for widgets that depend on the specific parts of the state. return _InheritedUntypedRebuilds( store: widget.store, child: widget.child, ); } } /// An UNTYPED inherited widget that is used by `isWaiting`, `isFailed` and `exceptionFor`. /// That's useful because these methods can find it without the knowing the St type, but /// it REBUILDS. Note: `_InheritedUntypedRebuilds._isOn` is true only after `state`, `isWaiting`, /// `isFailed` and `exceptionFor` are used for the first time. This is to make it faster by /// avoiding `updateShouldNotify` before this inner provider is necessary. /// This class now also supports selector-based rebuilds for fine-grained state subscriptions. class _InheritedUntypedRebuilds extends InheritedWidget { static var _isOn = false; final Store _store; _InheritedUntypedRebuilds({ Key? key, required Store store, required Widget child, }) : _store = store, super(key: key, child: child); @override _InheritedUntypedRebuildsElement createElement() { return _InheritedUntypedRebuildsElement(this); } @override bool updateShouldNotify(_InheritedUntypedRebuilds oldWidget) { return _isOn; } } /// Custom InheritedElement that supports selector-based rebuilds. class _InheritedUntypedRebuildsElement extends InheritedElement { _InheritedUntypedRebuildsElement(_InheritedUntypedRebuilds widget) : super(widget); @override _InheritedUntypedRebuilds get widget => super.widget as _InheritedUntypedRebuilds; @override void updateDependencies(Element dependent, Object? aspect) { // We need the state type, but this is untyped. We'll handle dynamic selectors. final dependencies = getDependencies(dependent); // DEBUG: Log dependency registration if (_debugSelectLogging) { print('[UPDATE_DEPS] Widget: ${dependent.widget.runtimeType}, ' 'Has existing deps: ${dependencies != null}, ' 'Deps type: ${dependencies?.runtimeType}, ' 'Aspect type: ${aspect?.runtimeType}'); } // Already listening to everything - don't override with selector. if (dependencies != null && dependencies is! SelectorDependency) { if (_debugSelectLogging) { print('[UPDATE_DEPS] Already listening to everything, returning'); } return; } if (aspect is SelectorAspect) { // Get or create the dependency object. final selectorDependency = (dependencies ?? SelectorDependency()) as SelectorDependency; if (_debugSelectLogging) { print('[UPDATE_DEPS] Selector aspect detected. ' 'Creating new dependency: ${dependencies == null}, ' 'Current selector count: ${selectorDependency.selectors.length}'); } // Clear selectors if flagged (from previous build). if (selectorDependency.shouldClearSelectors) { if (_debugSelectLogging) { print( '[UPDATE_DEPS] Clearing ${selectorDependency.selectors.length} old selectors'); } selectorDependency.shouldClearSelectors = false; selectorDependency.selectors.clear(); } // Schedule selector clearing for next tick. if (selectorDependency.shouldClearMutationScheduled == false) { selectorDependency.shouldClearMutationScheduled = true; if (_debugSelectLogging) { print('[UPDATE_DEPS] Scheduling selector clear for next microtask'); } Future.microtask(() { if (_debugSelectLogging) { print( '[UPDATE_DEPS] Microtask executed - marking selectors for clearing'); } selectorDependency ..shouldClearMutationScheduled = false ..shouldClearSelectors = true; }); } // Add the new selector. selectorDependency.selectors.add(aspect); setDependencies(dependent, selectorDependency); if (_debugSelectLogging) { print( '[UPDATE_DEPS] Added selector. New count: ${selectorDependency.selectors.length}'); } } else { // No aspect = listen to everything (context.state behavior). setDependencies(dependent, const Object()); if (_debugSelectLogging) { print('[UPDATE_DEPS] No aspect - listening to everything'); } } } @override void notifyDependent(InheritedWidget oldWidget, Element dependent) { final dependencies = getDependencies(dependent); if (_debugSelectLogging) { print('[NOTIFY] Widget: ${dependent.widget.runtimeType}, ' 'Has deps: ${dependencies != null}, ' 'Deps type: ${dependencies?.runtimeType}, ' 'Is dirty: ${dependent.dirty}'); } var shouldNotify = false; if (dependencies != null) { if (dependencies is SelectorDependency) { // OPTIMIZATION: Skip if widget is already being rebuilt. if (dependent.dirty) { if (_debugSelectLogging) { print('[NOTIFY] Widget already dirty, skipping'); } return; } if (_debugSelectLogging) { print('[NOTIFY] Checking ${dependencies.selectors.length} selectors'); } // Check each selector. int selectorIndex = 0; for (final updateShouldNotify in dependencies.selectors) { try { assert(() { _debugIsSelecting = true; return true; }()); // Call the aspect function with new value. shouldNotify = updateShouldNotify(widget._store.state); if (_debugSelectLogging) { print('[NOTIFY] Selector $selectorIndex returned: $shouldNotify'); } } finally { assert(() { _debugIsSelecting = false; return true; }()); } // OPTIMIZATION: Short-circuit on first true. if (shouldNotify) { if (_debugSelectLogging) { print('[NOTIFY] Selector triggered rebuild, stopping check'); } break; } selectorIndex++; } } else { // No selectors = watch everything. shouldNotify = true; if (_debugSelectLogging) { print('[NOTIFY] No selectors - watching everything'); } } } else { // If no dependencies registered yet, notify by default. shouldNotify = true; if (_debugSelectLogging) { print( '[NOTIFY] WARNING: No dependencies registered! Notifying by default'); } } if (shouldNotify) { if (_debugSelectLogging) { print('[NOTIFY] >>> REBUILDING ${dependent.widget.runtimeType}'); } dependent.didChangeDependencies(); } else { if (_debugSelectLogging) { print('[NOTIFY] Not rebuilding ${dependent.widget.runtimeType}'); } } } } StoreException _exceptionForWrongStoreType(Type type, {Object? debug}) { return StoreException( '''Error: No $type found. (debug info: ${debug.runtimeType}) To fix, please try: * Wrapping your MaterialApp with the StoreProvider, rather than an individual Route * Providing full type information to your Store, StoreProvider and StoreConnector * Ensure you are using consistent and complete imports. E.g. always use `import 'package:my_app/app_state.dart'; '''); } StoreException _exceptionForWrongStateType(Object? state, Type wrongType) { return StoreException( 'Error: State is of type ${state.runtimeType} but you typed it as $wrongType.'); } extension BuildContextExtensionForProviderAndConnector on BuildContext { // /// Provides easy access to the AsyncRedux store state from a BuildContext. /// /// Use this in your widget's build method to watch the current store state. /// Any widget that calls this will rebuild automatically when the state /// changes in any way (even if the part of the state we are actually using /// did not change). /// /// You cannot use [getState] in your `initState` method. If you do, it will /// throw an exception. See [getRead] for an alternative that can be used in /// `initState`. /// /// For convenience, it's recommended that you define this extension in your /// own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// AppState get state => getState(); /// AppState read() => getRead(); /// R select(R Function(AppState state) selector) => getSelect(selector); /// R? event(Evt Function(AppState state) selector) => getEvent(selector); /// } /// ``` /// /// Then use it like this: /// /// ```dart /// var state = context.state; /// ``` /// /// See also: /// /// - [getRead] if you don't want the widget to rebuild automatically when /// the state changes (use it with `context.read()`). This is useful when /// you want to read the state once, for example inside an event handler, /// or in your `initState` method. /// /// - [getSelect] to select a specific part of the state and only rebuild /// when that part changes (use it with `context.select()`). /// St getState() => _isMock // ? (_store.state as St) // : StoreProvider.state(this); /// Provides easy access to the AsyncRedux store state from a BuildContext. /// /// This is useful when you want to read the state once, for example /// inside an event handler, or in your `initState` method. /// Widgets using this will NOT rebuild automatically when the state changes. /// /// For convenience, it's recommended that you define this extension in your /// own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// AppState get state => getState(); /// AppState read() => getRead(); /// R select(R Function(AppState state) selector) => getSelect(selector); /// R? event(Evt Function(AppState state) selector) => getEvent(selector); /// } /// ``` /// /// Then use it like this: /// /// ```dart /// var state = context.read(); /// ``` /// /// See also: /// /// - [getState] if you want the widget to rebuild automatically on any state /// change (use it with `context.state`). /// /// - [getSelect] to select a specific part of the state and only rebuild /// when that part changes (use it with `context.select()`). /// St getRead() => _isMock ? (_store.state as St) : StoreProvider.state(this, notify: false); /// Consume an event from the state, and rebuild the widget when the event is /// dispatched. /// /// Events are one-time notifications that can be used to trigger side effects /// in widgets, such as showing a dialog, clearing a text field, or navigating /// to a new screen. Unlike regular state values, events are automatically /// "consumed" (marked as spent) after being read, ensuring they only trigger /// once. /// /// This method selects an event from the state using the provided [selector] /// function, consumes it, and returns its value. The widget will rebuild /// whenever a new (unspent) event is dispatched to the store. /// /// **Return value:** /// - For events with no generic type (`Evt`): Returns `true` if the event /// was dispatched, or `false` if it was already spent. /// - For events with a value type (`Evt`): Returns the event's value if /// it was dispatched, or `null` if it was already spent. /// /// For convenience, it's recommended that you define this extension in your /// own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// AppState get state => getState(); /// AppState read() => getRead(); /// R select(R Function(AppState state) selector) => getSelect(selector); /// R? event(Evt Function(AppState state) selector) => getEvent(selector); /// } /// ``` /// /// **Example with a boolean (value-less) event (clear text field):** /// /// In your state: /// ```dart /// class AppState { /// final Event clearTextEvt; /// AppState({required this.clearTextEvt}); /// } /// ``` /// /// In your action: /// ```dart /// class ClearTextAction extends ReduxAction { /// AppState reduce() => state.copy(clearTextEvt: Event()); /// } /// ``` /// /// In your widget: /// ```dart /// Widget build(BuildContext context) { /// var clearText = context.event((state) => state.clearTextEvt); /// if (clearText) controller.clear(); /// ... /// } /// ``` /// /// **Example with a typed event (display text in text field):** /// /// In your state: /// ```dart /// class AppState { /// final Event changeTextEvt; /// AppState({required this.changeTextEvt}); /// } /// ``` /// /// In your action: /// ```dart /// class ChangeTextAction extends ReduxAction { /// Future reduce() async { /// String newText = await fetchTextFromApi(); /// return state.copy(changeTextEvt: Event(newText)); /// } /// } /// ``` /// /// In your widget: /// ```dart /// Widget build(BuildContext context) { /// var newText = context.event((state) => state.changeTextEvt); /// if (newText != null) controller.text = newText; /// ... /// } /// ``` /// /// **Important notes:** /// - Events are consumed only once. After consumption, they are marked as /// "spent" and won't trigger again until a new event is dispatched. /// - Each event can be consumed by **only one widget**. If you need multiple /// widgets to react to the same trigger, use separate events in the state /// or consider using [EvtState] instead (which is not consumed). /// - Initialize events in the state as spent: `Event.spent()` or /// `Event.spent()`. /// - The widget will rebuild when a new event is dispatched, even if it has /// the same internal value as a previous event, because each event instance /// is unique. /// - The [selector] function must be pure and not cause side effects. /// /// See also: /// /// - [getState] to access the state and rebuild on any state change. /// - [getRead] to read the state without triggering rebuilds. /// - [getSelect] to select specific parts of the state and rebuild only when those parts change. /// - [Event] class documentation for more details on event behavior and lifecycle. /// R? getEvent(Evt Function(St state) selector, {bool debug = true}) { _assertEvent(debug); var evt = getSelect>(selector, debug: debug); return evt.consume(); } /// Select a specific part of the state and only rebuild when that part changes. /// /// This method allows fine-grained subscriptions to the state, rebuilding the widget /// only when the selected value actually changes, not on every state update. /// /// For convenience, it's recommended that you define this extension in your /// own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// AppState get state => getState(); /// AppState read() => getRead(); /// R select(R Function(AppState state) selector) => getSelect(selector); /// R? event(Evt Function(AppState state) selector) => getEvent(selector); /// } /// ``` /// /// Then use it like this: /// ```dart /// final userName = context.select((state) => state.user.name); /// ``` /// /// The widget will only rebuild when `state.user.name` changes, not when other /// parts of the state change. /// /// The comparison uses deep equality checking, so it works correctly with: /// - Primitive values (int, String, bool, etc.) /// - Lists (element-by-element comparison) /// - Maps (key-value pair comparison) /// - Sets (membership comparison) /// - Custom classes with proper `==` operator /// - IList, ISet, IMap from fast_immutable_collections /// /// IMPORTANT: The selector function must be pure and not cause side effects. /// Do not call other provider methods or dispatch actions inside the selector. /// /// See also: /// /// - [getState] if you want the widget to rebuild automatically on any state /// change (use it with `context.state`). /// /// - [getRead] if you don't want the widget to rebuild automatically when /// the state changes (use it with `context.read()`). /// /// The [debug] parameter, when true (the default), will throw an error if you /// try to use `context.select` outside the widget's `build` method. Set it to /// false to also allow usage in `didChangeDependencies`. Use this with care: /// once the debug check is off, invalid usage in methods like `initState` will /// no longer be detected. /// R getSelect(R Function(St state) selector, {bool debug = true}) { if (_isMock) return selector(_store.state as St); _assertSelect(debug); // Get the InheritedElement WITHOUT creating a dependency yet. final inheritedElement = getElementForInheritedWidgetOfExactType<_InheritedUntypedRebuilds>(); if (inheritedElement == null) { throw _exceptionForWrongStoreType(_typeOf<_InheritedUntypedRebuilds>()); } final provider = inheritedElement.widget as _InheritedUntypedRebuilds; St state; try { state = provider._store.state as St; } catch (error) { throw _exceptionForWrongStateType(provider._store.state, St); } // We only turn on rebuilds when select is used for the first time // (similar to how state() method works). _InheritedUntypedRebuilds._isOn = true; // Execute selector with debug tracking assert(() { _debugIsSelecting = true; return true; }()); final selected = selector(state); assert(() { _debugIsSelecting = false; return true; }()); if (_debugSelectLogging) { print('[SELECT] ${widget.runtimeType} selected value: $selected'); } // Register the dependency with an aspect function. dependOnInheritedElement( inheritedElement as _InheritedUntypedRebuildsElement, aspect: (dynamic newValue) { if (newValue == null) { return false; } // Re-run selector with new value and compare. assert(() { _debugIsSelecting = true; return true; }()); St newState; try { newState = newValue as St; } catch (_) { return false; } final newSelected = selector(newState); assert(() { _debugIsSelecting = false; return true; }()); // Use deep equality to compare selected values. return !const DeepCollectionEquality().equals(newSelected, selected); }, ); return selected; } void _assertSelect(bool debug) { assert(() { final widget = this.widget; // Check for unsupported contexts. if (widget is SliverWithKeepAliveWidget || widget is AutomaticKeepAliveClientMixin) { throw FlutterError( 'Tried to use `context.select` (or `context.getSelect`) ' 'inside a SliverList/SliderGridView.' '\n\n' 'This is likely a mistake, as instead of rebuilding only the item that cares ' 'about the selected value, this would rebuild the entire list/grid.' '\n\n' 'To fix, add a `Builder` or extract the content of `itemBuilder` in a separate widget:' '\n\n' 'ListView.builder(\n' ' itemBuilder: (context, index) {\n' ' return Builder(builder: (context) {\n' ' final todo = context.select((st) => st.list[index]);\n' ' return Text(todo.name);\n' ' });\n' ' },\n' ');\n'); } // Check we're in a build method. if (debug && !debugDoingBuild && widget is! LayoutBuilder && widget is! SliverLayoutBuilder) { throw FlutterError( 'Tried to use `context.select` (or `context.getSelect`) ' 'outside the widget `build` method.' '\n\n' 'See also: `context.read()` which you can use in `initState` and events handlers, ' 'because it will not rebuild widgets automatically when the state changes.\n'); } // Check for nested select calls. if (_debugIsSelecting) { throw FlutterError( 'Cannot call `context.select` inside the selector of another `context.select`.' '\n\n' 'The selector function must return a value immediately, without calling other selectors.\n'); } return true; }()); } void _assertEvent(bool debug) { assert(() { final widget = this.widget; // Check for unsupported contexts. if (widget is SliverWithKeepAliveWidget || widget is AutomaticKeepAliveClientMixin) { throw FlutterError( 'Tried to use `context.event` (or `context.getEvent`) ' 'inside a SliverList/SliderGridView.' '\n\n' 'This is likely a mistake, as instead of rebuilding only the item that cares ' 'about the selected value, this would rebuild the entire list/grid.' '\n\n' 'To fix, add a `Builder` or extract the content of `itemBuilder` in a separate widget:' '\n\n' 'ListView.builder(\n' ' itemBuilder: (context, index) {\n' ' return Builder(builder: (context) {\n' ' var clearText = context.event((state) => state.clearTextEvt);\n' ' if (clearText) controller.clear();\n' ' return TextField(controller: controller);\n' ' });\n' ' },\n' ');\n'); } // Check we're in a build method. if (debug && !debugDoingBuild && widget is! LayoutBuilder && widget is! SliverLayoutBuilder) { throw FlutterError( 'Tried to use `context.event` (or `context.getEvent`) ' 'outside the widget `build` method.' '\n\n' 'Note: If you also want to allow the usage in ' '`didChangeDependencies`, set `debug` to false in `context.getEvent`. ' 'Use with care, as invalid usage in methods like `initState` will ' 'no longer be detected once the debug check is off.\n'); } // Check for nested select calls. if (_debugIsSelecting) { throw FlutterError( 'Cannot call `context.event` inside the selector of another `context.event`.' '\n\n' 'The selector function must return a value immediately, without calling other selectors.\n'); } return true; }()); } /// Workaround to capture generics (used internally). static Type _typeOf() => T; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. /// /// ```dart /// context.dispatch(MyAction()); /// ``` /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatch] is of type [Dispatch]. /// /// See also: /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// FutureOr dispatch(ReduxAction action, {bool notify = true}) => _isMock ? _store.dispatch(action, notify: notify) : StoreProvider.dispatch(this, action, notify: notify); /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. In both cases, it returns a [Future] that resolves when /// the action finishes. /// /// ```dart /// await context.dispatchAndWait(DoThisFirstAction()); /// context.dispatch(DoThisSecondAction()); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Note: While the state change from the action's reducer will have been applied when the /// Future resolves, other independent processes that the action may have started may still /// be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns `Future`, /// which means you can also get the final status of the action after you `await` it: /// /// ```dart /// var status = await context.dispatchAndWait(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// Future dispatchAndWait(ReduxAction action, {bool notify = true}) => _isMock ? _store.dispatchAndWait(action, notify: notify) : StoreProvider.dispatchAndWait(this, action, notify: notify); /// Dispatches all given [actions] in parallel, applying their reducer, and /// possibly changing the store state. /// /// ```dart /// dispatchAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of these actions, even if it changes the state. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// List> dispatchAll(List> actions, {bool notify = true}) { return _isMock ? _store.dispatchAll(actions, notify: notify) as List> : StoreProvider.dispatchAll(this, actions, notify: notify); } /// Dispatches all given [actions] in parallel, applying their reducers, and /// possibly changing the store state. The actions may be sync or async. /// It returns a [Future] that resolves when ALL actions finish. /// /// ```dart /// await context.dispatchAndWaitAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily /// rebuild because of these actions, even if they change the state. /// /// Note: While the state change from the action's reducers will have been /// applied when the Future resolves, other independent processes that the /// action may have started may still be in progress. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAll] which dispatches all given actions in parallel. /// Future>> dispatchAndWaitAll( List> actions, { bool notify = true, }) => _isMock ? _store.dispatchAndWaitAll(actions, notify: notify) as Future>> : StoreProvider.dispatchAndWaitAll(this, actions, notify: notify); /// Dispatches the action, applying its reducer, and possibly changing the store state. /// However, if the action is ASYNC, it will throw a [StoreException]. /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily /// rebuild because of this action, even if it changes the state. /// /// Method [dispatchSync] is of type [DispatchSync]. It returns `ActionStatus`, /// which means you can also get the final status of the action: /// /// ```dart /// var status = context.dispatchSync(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) => _isMock ? _store.dispatchSync(action, notify: notify) : StoreProvider.dispatchSync(this, action, notify: notify); /// You can use [isWaiting] and pass it [actionOrTypeOrList] to check if: /// * A specific async ACTION is currently being processed. /// * An async action of a specific TYPE is currently being processed. /// * If any of a few given async actions or action types is currently being /// processed. /// /// If you wait for an action TYPE, then it returns false when: /// - The ASYNC action of the type is NOT currently being processed. /// - If the type is not really a type that extends [ReduxAction]. /// - The action of the type is a SYNC action (since those finish immediately). /// /// If you wait for an ACTION, then it returns false when: /// - The ASYNC action is NOT currently being processed. /// - If the action is a SYNC action (since those finish immediately). /// /// Trying to wait for any other type of object will return null and throw /// a [StoreException] after the async gap. /// /// Examples: /// /// ```dart /// // Waiting for an action TYPE: /// dispatch(MyAction()); /// if (context.isWaiting(MyAction)) { // Show a spinner } /// /// // Waiting for an ACTION: /// var action = MyAction(); /// dispatch(action); /// if (context.isWaiting(action)) { // Show a spinner } /// /// // Waiting for any of the given action TYPES: /// dispatch(BuyAction()); /// if (context.isWaiting([BuyAction, SellAction])) { // Show a spinner } /// ``` bool isWaiting(Object actionOrTypeOrList) => _isMock ? _store.isWaiting(actionOrTypeOrList) : StoreProvider.isWaiting(this, actionOrTypeOrList); /// Returns true if an [actionOrTypeOrList] failed with an [UserException]. /// /// Example: /// /// ```dart /// if (context.isFailed(MyAction)) { // Show an error message. } /// ``` bool isFailed(Object actionOrTypeOrList) => _isMock ? _store.isFailed(actionOrTypeOrList) : StoreProvider.isFailed(this, actionOrTypeOrList); /// Returns the [UserException] of the [actionTypeOrList] that failed. /// /// The [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Example: /// /// ```dart /// if (context.isFailed(SaveUserAction)) Text(context.exceptionFor(SaveUserAction)!.reason ?? ''); /// ``` UserException? exceptionFor(Object actionOrTypeOrList) => _isMock ? _store.exceptionFor(actionOrTypeOrList) : StoreProvider.exceptionFor(this, actionOrTypeOrList); /// Removes the given [actionTypeOrList] from the list of action types that failed. /// /// Note that dispatching an action already removes that action type from the /// exceptions list. This removal happens as soon as the action is dispatched, /// not when it finishes. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// void clearExceptionFor(Object actionOrTypeOrList) => _isMock ? _store.clearExceptionFor(actionOrTypeOrList) : StoreProvider.clearExceptionFor(this, actionOrTypeOrList); /// Given the BuildContext, provides easy access to the optional AsyncRedux /// store "environment" that you may have defined. The environment is considered /// immutable and should not change during the app's lifecycle. /// /// This allows you to show different UI based on the environment (if the app is running /// in production, staging, development, testing), for example showing a debug banner /// in development environment but not in production. /// /// Note that accessing the environment does not trigger any widget rebuilds. /// /// For convenience, given that you will have your own `Environment` class, /// it's recommended that you define this extension in your own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// Environment get env => getEnvironment() as Environment; /// } /// ``` /// /// Then use it like this: /// /// ```dart /// var environment = context.env; /// ``` /// /// Or else you can directly create a boolean getter: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// Environment get _env => getEnvironment() as Environment; /// bool get isProduction => _env.isProduction; /// bool get isStaging => _env.isStaging; /// bool get isDevelopment => _env.isDevelopment /// bool get isTesting => _env.isTesting; /// } /// ``` /// /// Then use it like this: /// /// ```dart /// if (context.isProduction) return Text('Welcome to the app!'); /// else return Text('Welcome to the development version of the app!'); /// ``` Object? getEnvironment() { if (_isMock) return _store.environment; Store store = StoreProvider.backdoorInheritedWidget(this); return store.environment; } /// Given the BuildContext, provides easy access to the optional AsyncRedux /// store "configuration" that you may have defined. /// /// This allows you to show different UI based on the configuration (if the app should /// show certain features, or use certain API endpoints, etc.). The configuration is /// considered immutable and should not change during the app's lifecycle. /// /// Note that accessing the configuration does not trigger any widget rebuilds. /// /// For convenience, given that you will have your own `Configuration` class, /// it's recommended that you define this extension in your own code: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// Configuration get config => getConfiguration() as Configuration; /// } /// ``` /// /// Then use it like this: /// /// ```dart /// var configuration = context.config; /// ``` /// /// Or else you can directly create a boolean getter: /// /// ```dart /// extension BuildContextExtension on BuildContext { /// Configuration get _config => getConfiguration() as Configuration; /// bool get isA => _config.abTesting.a; /// bool get isB => _config.abTesting.b; /// bool get showAdminFeatures => _config.showAdminFeatures; /// } /// ``` /// /// Then use it like this: /// /// ```dart /// if (context.isA) return Text('Welcome', style: TextStyle(color: Colors.blue)); /// else return Text('Welcome', style: TextStyle(color: Colors.red)); /// ``` Object? getConfiguration() { if (_isMock) return _store.configuration; Store store = StoreProvider.backdoorInheritedWidget(this); return store.configuration; } /// Allows [MockBuildContext] to be used for testing. bool get _isMock => this is MockBuildContext; /// Only use this after checking [_isMock]. Store get _store => (this as MockBuildContext).store; } /// This extension allows you to write `dispatch()` instead of /// `context.dispatch()` inside the [State] of a [StatefulWidget]. It /// also works for `dispatchAndWait()`, `dispatchAll()`, `dispatchAndWaitAll()`, /// `dispatchSync()`, `isWaiting()`, `isFailed()`, `exceptionFor()`, and /// `clearExceptionFor()`. /// /// - It is compatible with testing with [MockBuildContext]. /// /// - If your app has multiple [StoreProvider] widgets, you should continue /// using `context.dispatch()`. /// extension StatefulWidgetExtensionForProviderAndConnector on State { // /// Dispatches the action, applying its reducer, and possibly changing the /// store state. The action may be sync or async. /// /// ```dart /// dispatch(MyAction()); /// ``` /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// Method [dispatch] is of type [Dispatch]. /// /// IMPORTANT: You can use `dispatch()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatch()` instead. /// /// See also: /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// FutureOr dispatch(ReduxAction action, {bool notify = true}) => StoreProvider.backdoorStaticGlobal().dispatch(action, notify: notify); /// Dispatches the action, applying its reducer, and possibly changing the /// store state. The action may be sync or async. In both cases, it returns a /// [Future] that resolves when the action finishes. /// /// ```dart /// await dispatchAndWait(DoThisFirstAction()); /// dispatch(DoThisSecondAction()); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// Note: While the state change from the action's reducer will have been /// applied when the Future resolves, other independent processes that the /// action may have started may still be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns /// `Future`, which means you can also get the final status of /// the action after you `await` it: /// /// ```dart /// var status = await dispatchAndWait(MyAction()); /// ``` /// /// IMPORTANT: You can use `dispatchAndWait()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchAndWait()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// Future dispatchAndWait(ReduxAction action, {bool notify = true}) => StoreProvider.backdoorStaticGlobal() .dispatchAndWait(action, notify: notify); /// Dispatches all given [actions] in parallel, applying their reducer, and /// possibly changing the store state. /// /// ```dart /// dispatchAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of these actions, even if it changes the state. /// /// IMPORTANT: You can use `dispatchAll()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchAll()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// List> dispatchAll(List> actions, {bool notify = true}) => StoreProvider.backdoorStaticGlobal().dispatchAll(actions, notify: notify) as List>; /// Dispatches all given [actions] in parallel, applying their reducers, and /// possibly changing the store state. The actions may be sync or async. /// It returns a [Future] that resolves when ALL actions finish. /// /// ```dart /// await dispatchAndWaitAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of these actions, even if they change the state. /// /// Note: While the state change from the action's reducers will have been /// applied when the Future resolves, other independent processes that the /// action may have started may still be in progress. /// /// IMPORTANT: You can use `dispatchAndWaitAll()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchAndWaitAll()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAll] which dispatches all given actions in parallel. /// Future>> dispatchAndWaitAll( List> actions, { bool notify = true, }) => StoreProvider.backdoorStaticGlobal().dispatchAndWaitAll(actions, notify: notify) as Future>>; /// Dispatches the action, applying its reducer, and possibly changing the store /// state. However, if the action is ASYNC, it will throw a [StoreException]. /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// Method [dispatchSync] is of type [DispatchSync]. It returns `ActionStatus`, /// which means you can also get the final status of the action: /// /// ```dart /// var status = dispatchSync(MyAction()); /// ``` /// /// IMPORTANT: You can use `dispatchSync()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchSync()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) => StoreProvider.backdoorStaticGlobal().dispatchSync(action, notify: notify); } /// This extension allows you to write `dispatch()` instead of /// `context.dispatch()` inside a [StatelessWidget]. It also works for /// `dispatchAndWait()`, `dispatchAll()`, `dispatchAndWaitAll()`, /// `dispatchSync()`, `isWaiting()`, `isFailed()`, `exceptionFor()`, and /// `clearExceptionFor()`. /// /// - It is compatible with testing with [MockBuildContext]. /// /// - If your app has multiple [StoreProvider] widgets, you should continue /// using `context.dispatch()`. /// extension StatelessWidgetExtensionForProviderAndConnector on StatelessWidget { // /// Dispatches the action, applying its reducer, and possibly changing the /// store state. The action may be sync or async. /// /// ```dart /// dispatch(MyAction()); /// ``` /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// Method [dispatch] is of type [Dispatch]. /// /// IMPORTANT: You can use `dispatch()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatch()` instead. /// /// See also: /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// FutureOr dispatch(ReduxAction action, {bool notify = true}) => StoreProvider.backdoorStaticGlobal().dispatch(action, notify: notify); /// Dispatches the action, applying its reducer, and possibly changing the /// store state. The action may be sync or async. In both cases, it returns a /// [Future] that resolves when the action finishes. /// /// ```dart /// await dispatchAndWait(DoThisFirstAction()); /// dispatch(DoThisSecondAction()); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// Note: While the state change from the action's reducer will have been /// applied when the Future resolves, other independent processes that the /// action may have started may still be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns /// `Future`, which means you can also get the final status of /// the action after you `await` it: /// /// ```dart /// var status = await dispatchAndWait(MyAction()); /// ``` /// /// IMPORTANT: You can use `dispatchAndWait()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchAndWait()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// Future dispatchAndWait(ReduxAction action, {bool notify = true}) => StoreProvider.backdoorStaticGlobal() .dispatchAndWait(action, notify: notify); /// Dispatches all given [actions] in parallel, applying their reducer, and /// possibly changing the store state. /// /// ```dart /// dispatchAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of these actions, even if it changes the state. /// /// IMPORTANT: You can use `dispatchAll()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchAll()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchAndWaitAll] which dispatches all given actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// List> dispatchAll(List> actions, {bool notify = true}) => StoreProvider.backdoorStaticGlobal().dispatchAll(actions, notify: notify) as List>; /// Dispatches all given [actions] in parallel, applying their reducers, and /// possibly changing the store state. The actions may be sync or async. /// It returns a [Future] that resolves when ALL actions finish. /// /// ```dart /// await dispatchAndWaitAll([BuyAction('IBM'), SellAction('TSLA')]); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of these actions, even if they change the state. /// /// Note: While the state change from the action's reducers will have been /// applied when the Future resolves, other independent processes that the /// action may have started may still be in progress. /// /// IMPORTANT: You can use `dispatchAndWaitAll()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchAndWaitAll()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAll] which dispatches all given actions in parallel. /// Future>> dispatchAndWaitAll( List> actions, { bool notify = true, }) => StoreProvider.backdoorStaticGlobal().dispatchAndWaitAll(actions, notify: notify) as Future>>; /// Dispatches the action, applying its reducer, and possibly changing the store /// state. However, if the action is ASYNC, it will throw a [StoreException]. /// /// If you pass the [notify] parameter as `false`, widgets will not /// necessarily rebuild because of this action, even if it changes the state. /// /// Method [dispatchSync] is of type [DispatchSync]. It returns `ActionStatus`, /// which means you can also get the final status of the action: /// /// ```dart /// var status = dispatchSync(MyAction()); /// ``` /// /// IMPORTANT: You can use `dispatchSync()` only when your app has a single /// [StoreProvider], which is almost always true. Otherwise, you need to use /// `context.dispatchSync()` instead. /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) => StoreProvider.backdoorStaticGlobal().dispatchSync(action, notify: notify); } ================================================ FILE: lib/src/store_tester.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:async'; import 'dart:collection'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Action; import '../async_redux.dart'; import 'connector_tester.dart'; /// Predicate used in [StoreTester.waitCondition]. /// Return true to stop waiting, and get the last state. typedef StateCondition = bool Function(TestInfo info); /// Helps testing the store, actions, and sync/async reducers. /// /// For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// class StoreTester { // /// The default timeout in seconds is 10 minutes. /// This value is not final and can be modified. static int defaultTimeout = 60 * 10; /// If the default debug info should be printed to the console or not. static bool printDefaultDebugInfo = true; static TestInfoPrinter defaultTestInfoPrinter = (TestInfo info) { if (printDefaultDebugInfo) print(info); }; static VoidCallback defaultNewStorePrinter = () { if (printDefaultDebugInfo) print("New StoreTester."); }; final Store _store; final List _ignore; late StreamSubscription _subscription; late Completer> _completer; late Queue>> _futures; Store get store => _store; St get state => _store.state; /// The last TestInfo read after some wait method. late TestInfo lastInfo; /// The current TestInfo. TestInfo get currentTestInfo => _currentTestInfo; late TestInfo _currentTestInfo; /// The [StoreTester] makes it easy to test both sync and async reducers. /// You may dispatch some action, wait for it to finish or wait until some /// arbitrary condition is met, and then check the resulting state. /// /// The [StoreTester] will, by default, print some default debug /// information to the console. You can disable these prints globally /// by making `StoreTester.printDefaultDebugInfo = false`. /// Note you can also provide your own custom [testInfoPrinter]. /// /// If [shouldThrowUserExceptions] is true, all errors will be thrown, /// and not swallowed, including UserExceptions. Use this in all tests /// that should throw no errors. Pass [shouldThrowUserExceptions] as /// false when you are testing code that should throw UserExceptions. /// These exceptions will then silently go to the `errors` queue, /// where you can assert they exist with the right error messages. /// StoreTester({ required St initialState, TestInfoPrinter? testInfoPrinter, List? ignore, bool syncStream = false, ErrorObserver? errorObserver, bool shouldThrowUserExceptions = false, Map? mocks, }) : this.from( MockStore( initialState: initialState, syncStream: syncStream, errorObserver: errorObserver ?? // (shouldThrowUserExceptions ? TestErrorObserver() : null), mocks: mocks, ), testInfoPrinter: testInfoPrinter, ignore: ignore); /// Create a StoreTester from a store that already exists. StoreTester.from( Store store, { TestInfoPrinter? testInfoPrinter, List? ignore, }) : _ignore = ignore ?? const [], _store = store { if (testInfoPrinter != null) _store.initTestInfoPrinter(testInfoPrinter); else if (_store.testInfoPrinter == null) // _store.initTestInfoPrinter(defaultTestInfoPrinter); _listen(); defaultNewStorePrinter(); } /// Create a StoreTester from a store that already exists, /// but don't print anything to the console. StoreTester.simple(this._store) : _ignore = const [] { _listen(); } Map? get mocks => (store as MockStore).mocks; set mocks(Map? _mocks) => (store as MockStore).mocks = _mocks; MockStore addMock(Type actionType, dynamic mock) { (store as MockStore).addMock(actionType, mock); return store as MockStore; } MockStore addMocks(Map mocks) { (store as MockStore).addMocks(mocks); return store as MockStore; } MockStore clearMocks() { (store as MockStore).clearMocks(); return store as MockStore; } FutureOr dispatch(ReduxAction action, {bool notify = true}) => store.dispatch(action, notify: notify); ActionStatus dispatchSync(ReduxAction action, {bool notify = true}) => store.dispatchSync(action, notify: notify); @Deprecated("Use `dispatchAndWait` instead. This will be removed.") Future dispatchAsync(ReduxAction action, {bool notify = true}) => store.dispatchAndWait(action, notify: notify); Future dispatchAndWait(ReduxAction action, {bool notify = true}) => store.dispatchAndWait(action, notify: notify); /// Dispatches [action], and then waits until it finishes. /// Returns the info after the action finishes. **Ignores other** actions. /// /// Example use: /// /// var action = MyAction(); /// await storeTester.dispatchAndWait(action); /// /// Note, this is the same as doing: /// /// var action = MyAction(); /// storeTester.dispatch(action); /// await storeTester.wait(action); /// Future> dispatchAndWaitGetInfo(ReduxAction action) { store.dispatch(action); return waitUntilAction(action); } void defineState(St state) => _store.defineState(state); /// Dispatches an action that changes the current state to the one provided by you. /// Then, runs until that action is dispatched and finished (ignoring other actions). /// Returns the info after the action finishes, containing the given state. /// /// Example use: /// /// var info = await storeTester.dispatchState(MyState(123)); /// expect(info.state, MyState(123)); /// Future> dispatchState(St state) async { var action = _NewStateAction(state); dispatch(action); TestInfo? testInfo; while (testInfo == null || !identical(testInfo.action, action) || testInfo.isINI) { testInfo = await _next(); } lastInfo = testInfo; return testInfo; } /// Returns a mutable copy of the global ignore list. List get ignore => List.of(_ignore); /// Runs until the predicate function [condition] returns true. /// This function will receive each testInfo, from where it can /// access the state, action, errors etc. /// When [testImmediately] is true (the default), it will test the condition /// immediately when the method is called. If the condition is true, the /// method will return immediately, without waiting for any actions to be /// dispatched. /// When [testImmediately] is false, it will only test /// the condition once an action is dispatched. /// Only END states will be received, unless you pass [ignoreIni] as false. /// Returns the info after the condition is met. /// Future> waitConditionGetLast( StateCondition condition, { bool testImmediately = true, bool ignoreIni = true, int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; var infoList = await waitCondition( condition, testImmediately: testImmediately, ignoreIni: ignoreIni, timeoutInSeconds: timeoutInSeconds, ); return infoList.last; } /// Runs until the predicate function [condition] returns true. /// This function will receive each testInfo, from where it can /// access the state, action, errors etc. /// When [testImmediately] is true (the default), it will test the condition /// immediately when the method is called. If the condition is true, the /// method will return immediately, without waiting for any actions to be /// dispatched. /// When [testImmediately] is false, it will only test /// the condition once an action is dispatched. /// Only END states will be received, unless you pass [ignoreIni] as false. /// Returns a list with all info until the condition is met. /// Future> waitCondition( StateCondition condition, { bool testImmediately = true, bool ignoreIni = true, int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; TestInfoList infoList = TestInfoList(); if (testImmediately) { var currentTestInfoWithoutAction = TestInfo( _currentTestInfo.state, false, null, null, null, _currentTestInfo.dispatchCount, _currentTestInfo.reduceCount, _currentTestInfo.errors, ); if (condition(currentTestInfoWithoutAction)) { infoList._add(currentTestInfoWithoutAction); lastInfo = infoList.last; return infoList; } } TestInfo testInfo = await _next(timeoutInSeconds: timeoutInSeconds); while (true) { if (ignoreIni) while (testInfo.ini) testInfo = await (_next( timeoutInSeconds: timeoutInSeconds, )); infoList._add(testInfo); if (condition(testInfo)) break; else testInfo = await _next(timeoutInSeconds: timeoutInSeconds); } lastInfo = infoList.last; return infoList; } /// If [error] is a Type, runs until after an action throws an error of this exact type. /// If [error] is NOT a Type, runs until after an action throws this [error] (using equals). /// /// You can also, instead, define [processedError], which is the error after wrapped by the /// action's wrapError() method. Note, if you define both [error] and [processedError], /// both need to match. /// /// Returns the info after the error condition is met. /// Future> waitUntilErrorGetLast({ Object? error, Object? processedError, int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; var infoList = await waitUntilError( error: error, processedError: processedError, timeoutInSeconds: timeoutInSeconds, ); return infoList.last; } /// If [error] is a Type, runs until after an action throws an error of this exact type. /// If [error] is NOT a Type, runs until after an action throws this [error] (using equals). /// /// You can also, instead, define [processedError], which is the error after wrapped by the /// action's wrapError() method. Note, if you define both [error] and [processedError], /// both need to match. /// /// Returns a list with all info until the error condition is met. /// Future> waitUntilError({ Object? error, Object? processedError, int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; assert(error != null || processedError != null); var condition = (TestInfo info) => (error == null || (error is Type && info.error.runtimeType == error) || (error is! Type && info.error == error)) && (processedError == null || (processedError is Type && // info.processedError.runtimeType == processedError) || (processedError is! Type && // info.processedError == processedError)); var infoList = await waitCondition( condition, ignoreIni: true, timeoutInSeconds: timeoutInSeconds, ); lastInfo = infoList.last; return infoList; } /// Expects **one action** of the given type to be dispatched, and waits until it finishes. /// Returns the info after the action finishes. /// Will fail with an exception if an unexpected action is seen. Future> wait(Type actionType) async => // waitAllGetLast([actionType]); /// Runs until an action of the given type is dispatched, and then waits until it finishes. /// Returns the info after the action finishes. **Ignores other** actions types. /// Future> waitUntil( Type actionType, { int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; TestInfo? testInfo; while ( (testInfo == null) || (testInfo.type != actionType) || testInfo.isINI) { testInfo = await _next(timeoutInSeconds: timeoutInSeconds); } lastInfo = testInfo; return testInfo; } /// Runs until an action of the given types is dispatched, and then waits until it /// finishes. Returns the info after the action finishes. **Ignores other** actions types. /// Future> waitUntilAny( List actionTypes, { int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; TestInfo? testInfo; while ((testInfo == null) || (!actionTypes.contains(testInfo.type)) || testInfo.isINI) { testInfo = await _next(timeoutInSeconds: timeoutInSeconds); } lastInfo = testInfo; return testInfo; } /// Runs until all actions of the given types are dispatched and finish, in any order. /// Returns a list with all info until the last action finishes. **Ignores other** actions types. /// Future> waitUntilAll( List actionTypes, { bool ignoreIni = true, int? timeoutInSeconds, }) async { assert(actionTypes.isNotEmpty); timeoutInSeconds ??= defaultTimeout; TestInfoList infoList = TestInfoList(); Set actionsIni = Set.from(actionTypes); Set actionsEnd = {}; TestInfo? testInfo; while (actionsIni.isNotEmpty || actionsEnd.isNotEmpty) { testInfo = await _next(timeoutInSeconds: timeoutInSeconds); if (!ignoreIni || testInfo.isEND) infoList._add(testInfo); Type actionType = testInfo.action.runtimeType; if (testInfo.isINI) { if (actionsIni.remove(actionType)) { actionsEnd.add(actionType); } } else actionsEnd.remove(actionType); } lastInfo = infoList.last; return infoList; } /// Runs until all actions of the given types are dispatched and finish, in any order. /// Returns the info after they all finish. **Ignores other** actions types. /// Future> waitUntilAllGetLast( List actionTypes, { bool ignoreIni = true, int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; var infoList = await waitUntilAll( actionTypes, ignoreIni: ignoreIni, timeoutInSeconds: timeoutInSeconds, ); return infoList.last; } /// Runs until the exact given action is dispatched, and then waits until it finishes. /// Returns the info after the action finishes. **Ignores other** actions. /// /// Example use: /// /// var action = MyAction(); /// storeTester.dispatch(action); /// await storeTester.waitUntilAction(action); /// Future> waitUntilAction( ReduxAction action, { int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; TestInfo? testInfo; while (testInfo == null || testInfo.action != action || testInfo.isINI) { testInfo = await _next(timeoutInSeconds: timeoutInSeconds); } lastInfo = testInfo; return testInfo; } /// Runs until **all** given actions types are dispatched, **in order**. /// Waits until all of them are finished. /// Returns the info after all actions finish. /// Will fail with an exception if an unexpected action is seen, /// or if any of the expected actions are dispatched in the wrong order. /// /// If you pass action types to [ignore], they will be ignored (the test won't fail when /// encountering them, and won't collect testInfo for them). However, if an action type /// exists both in [actionTypes] and [ignore], it will be expected in that particular order, /// and the others of that type will be ignored. This method will remember all ignored actions /// and wait for them to finish, so that they don't "leak" to the next wait. /// /// If [ignore] is null, it will use the global ignore provided in the /// [StoreTester] constructor, if any. If [ignore] is an empty list, it /// will disable that global ignore. /// Future> waitAllGetLast( List actionTypes, { List? ignore, }) async { assert(actionTypes.isNotEmpty); ignore ??= _ignore; var infoList = await waitAll(actionTypes, ignore: ignore); lastInfo = infoList.last; return infoList.last; } /// Runs until **all** given actions types are dispatched, in **any order**. /// Waits until all of them are finished. Returns the info after all actions finish. /// Will fail with an exception if an unexpected action is seen. /// /// If you pass action types to [ignore], they will be ignored (the test won't fail when /// encountering them, and won't collect testInfo for them). This method will remember all /// ignored actions and wait for them to finish, so that they don't "leak" to the next wait. /// An action type cannot exist in both [actionTypes] and [ignore] lists. /// Future> waitAllUnorderedGetLast( List actionTypes, { int? timeoutInSeconds, List? ignore, }) async { timeoutInSeconds ??= defaultTimeout; return (await waitAllUnordered( actionTypes, timeoutInSeconds: timeoutInSeconds, ignore: ignore, )) .last; } /// Runs until **all** given actions types are dispatched, **in order**. /// Waits until all of them are finished. /// Returns the info after all actions finish. /// Will fail with an exception if an unexpected action is seen, /// or if any of the expected actions are dispatched in the wrong order. /// /// If you pass action types to [ignore], they will be ignored (the test won't fail when /// encountering them, and won't collect testInfo for them). However, if an action type /// exists both in [actionTypes] and [ignore], it will be expected in that particular order, /// and the others of that type will be ignored. This method will remember all ignored actions /// and wait for them to finish, so that they don't "leak" to the next wait. /// /// If [ignore] is null, it will use the global ignore provided in the /// [StoreTester] constructor, if any. If [ignore] is an empty list, it /// will disable that global ignore. /// /// This method is the same as `waitAllGetLast`, but instead of returning /// just the last info, it returns a list with the end info for each action. /// Future> waitAll( List actionTypes, { List? ignore, }) async { assert(actionTypes.isNotEmpty); ignore ??= _ignore; TestInfoList infoList = TestInfoList(); TestInfo? testInfo; Queue expectedActionTypesINI = Queue.from(actionTypes); // These are for better error messages only. List obtainedIni = []; List ignoredIni = []; List expectedActionsEND = []; List expectedActionsENDIgnored = []; while (expectedActionTypesINI.isNotEmpty || expectedActionsEND.isNotEmpty || expectedActionsENDIgnored.isNotEmpty) { // testInfo = await _next(); // Action INI must all exist, in order. if (testInfo.isINI) { // bool wasIgnored = ignore.contains(testInfo.type) && (expectedActionTypesINI.isEmpty || // expectedActionTypesINI.first != testInfo.type); /// Record this action, so that later we can wait until it ends. if (wasIgnored) { expectedActionsENDIgnored.add(testInfo.action); ignoredIni.add(testInfo.type); // // For better error messages only. } // else { expectedActionsEND.add(testInfo.action); obtainedIni.add(testInfo.type); // For better error messages only. Type? expectedActionTypeINI = expectedActionTypesINI.isEmpty ? // null : expectedActionTypesINI.removeFirst(); if (testInfo.type != expectedActionTypeINI) throw StoreException("Got this unexpected action: " "${testInfo.type} INI.\n" "Was expecting: $expectedActionTypeINI INI.\n" "obtainedIni: $obtainedIni\n" "ignoredIni: $ignoredIni"); } } // // Action END must all exist, but the order doesn't matter. else { bool wasRemoved = expectedActionsEND.remove(testInfo.action); if (wasRemoved) infoList._add(testInfo); else wasRemoved = expectedActionsENDIgnored.remove(testInfo.action); if (!wasRemoved) throw StoreException("Got this unexpected action: " "${testInfo.type} END.\n" "obtainedIni: $obtainedIni\n" "ignoredIni: $ignoredIni"); } } lastInfo = infoList.last; return infoList; } /// The same as `waitAllUnorderedGetLast`, but instead of returning just the last info, /// it returns a list with the end info for each action. /// /// If you pass action types to [ignore], they will be ignored (the test won't fail when /// encountering them, and won't collect testInfo for them). This method will remember all /// ignored actions and wait for them to finish, so that they don't "leak" to the next wait. /// An action type cannot exist in both [actionTypes] and [ignore] lists. /// /// If [ignore] is null, it will use the global ignore provided in the /// [StoreTester] constructor, if any. If [ignore] is an empty list, it /// will disable that global ignore. /// Future> waitAllUnordered( List actionTypes, { int? timeoutInSeconds, List? ignore, }) async { assert(actionTypes.isNotEmpty); timeoutInSeconds ??= defaultTimeout; ignore ??= _ignore; // Actions which are expected can't also be ignored. var intersection = ignore.toSet().intersection(actionTypes.toSet()); if (intersection.isNotEmpty) throw StoreException("Actions $intersection " "should not be expected and ignored."); TestInfoList infoList = TestInfoList(); List actionsIni = List.from(actionTypes); List actionsEnd = List.from(actionTypes); TestInfo? testInfo; // Saves ignored actions INI. // Note: This relies on Actions not overriding operator ==. List ignoredActions = []; while (actionsIni.isNotEmpty || actionsEnd.isNotEmpty) { try { testInfo = await _next(timeoutInSeconds: timeoutInSeconds); while (ignore.contains(testInfo!.type)) { // // Saves ignored actions. if (ignore.contains(testInfo.type)) { if (testInfo.isINI) ignoredActions.add(testInfo.action); else ignoredActions.remove(testInfo.action); } testInfo = await (_next(timeoutInSeconds: timeoutInSeconds)); } } on StoreExceptionTimeout catch (error) { error.addDetail("These actions were not dispatched: " "$actionsIni INI."); error.addDetail("These actions haven't finished: " "$actionsEnd END."); rethrow; } var action = testInfo.type; if (testInfo.isINI) { if (!actionsIni.remove(action)) throw StoreException("Unexpected action was dispatched: " "$action INI."); } else { if (!actionsEnd.remove(action)) throw StoreException("Unexpected action was dispatched: " "$action END."); // Only save the END states. infoList._add(testInfo); } } // Wait for all ignored actions to finish, so that they don't "leak" to the next wait. while (ignoredActions.isNotEmpty) { testInfo = await _next(); var wasIgnored = ignoredActions.remove(testInfo.action); if (!wasIgnored && ignore.contains(testInfo.type)) { if (testInfo.isINI) ignoredActions.add(testInfo.action); else ignoredActions.remove(testInfo.action); continue; } if (!testInfo.isEND || !wasIgnored) throw StoreException("Got this unexpected action: " "${testInfo.type} ${testInfo.ini ? "INI" : "END"}."); } lastInfo = infoList.last; return infoList; } void _listen() { _store.initTestInfoController(); _subscription = _store.onReduce.listen(_completeFuture); _completer = Completer(); _futures = Queue()..addLast(_completer.future); _currentTestInfo = TestInfo( state, false, null, null, null, store.dispatchCount, store.reduceCount, store.errors, ); } Future> _next({ int? timeoutInSeconds, }) async { timeoutInSeconds ??= defaultTimeout; if (_futures.isEmpty) { _completer = Completer(); _futures.addLast(_completer.future); } var result = _futures.removeFirst(); _currentTestInfo = await result.timeout( Duration(seconds: timeoutInSeconds), onTimeout: (() => throw StoreExceptionTimeout()), ); return _currentTestInfo; } void _completeFuture(TestInfo reduceInfo) { _completer.complete(reduceInfo); _completer = Completer(); _futures.addLast(_completer.future); } Future cancel() async => await _subscription.cancel(); /// Helps testing the `StoreConnector`s methods, such as `onInit`, /// `onDispose` and `onWillChange`. /// /// For example, suppose you have a `StoreConnector` which dispatches /// `SomeAction` on its `onInit`. How could you test that? /// /// ``` /// class MyConnector extends StatelessWidget { /// Widget build(BuildContext context) => StoreConnector( /// vm: () => _Factory(), /// onInit: _onInit, /// builder: (context, vm) { ... } /// } /// /// void _onInit(Store store) => store.dispatch(SomeAction()); /// } /// /// var storeTester = StoreTester(...); /// var connectorTester = storeTester.getConnectorTester(MyConnector()); /// connectorTester.runOnInit(); /// var info = await tester.waitUntil(SomeAction); /// ``` /// ConnectorTester getConnectorTester( StatelessWidget widgetConnector) => ConnectorTester(store, widgetConnector); } /// List of test information, before or after some actions are dispatched. class TestInfoList { final List> _info = []; TestInfo get last => _info.last; TestInfo get first => _info.first; /// The number of dispatched actions. int get length => _info.length; /// Returns info corresponding to the end of the index-th dispatched action type. TestInfo getIndex(int index) => _info[index]; /// Returns the first info corresponding to the end of the given action type. TestInfo? operator [](Type actionType) => _info.firstWhereOrNull((info) => info.type == actionType); /// Returns the n-th info corresponding to the end of the given action type /// Note: N == 1 is the first one. TestInfo? get(Type actionType, [int n = 1]) => _info.firstWhereOrNull( (info) { var ifFound = (info.type == actionType); if (ifFound) n--; return ifFound && (n == 0); }, ); /// Returns all info corresponding to the action type. List> getAll(Type actionType) { return _info.where((info) => info.type == actionType).toList(); } void forEach(void action(TestInfo element)) => _info.forEach(action); TestInfo firstWhere( bool test(TestInfo element), { TestInfo orElse()?, }) => _info.firstWhere(test, orElse: orElse); TestInfo lastWhere( bool test(TestInfo element), { TestInfo orElse()?, }) => _info.lastWhere(test, orElse: orElse); TestInfo singleWhere( bool test(TestInfo element), { TestInfo orElse()?, }) => _info.singleWhere(test, orElse: orElse); Iterable> where( bool test( TestInfo element, )) => _info.where(test); Iterable map(T f(TestInfo element)) => _info.map(f); List> toList({ bool growable = true, }) => _info.toList(growable: growable); Set> toSet() => _info.toSet(); bool get isEmpty => length == 0; bool get isNotEmpty => !isEmpty; void _add(TestInfo info) => _info.add(info); } /// Note: The [StoreExceptionTimeout] is used in the [StoreTester] only. /// In the wait methods of the [Store] (like [Store.waitCondition] etc), /// the timeouts throw [TimeoutException]. /// class StoreExceptionTimeout extends StoreException { StoreExceptionTimeout() : super("Timeout."); final List _details = []; List get details => _details; void addDetail(String detail) => _details.add(detail); @override String toString() => (details.isEmpty) ? msg : // msg + "\nDetails:\n" + details.map((d) => "- $d").join("\n"); @override bool operator ==(Object other) => identical(this, other) || // other is StoreExceptionTimeout && runtimeType == other.runtimeType; @override int get hashCode => 0; } /// During tests, use this error observer if you want all errors to be thrown, /// and not swallowed, including UserExceptions. You should probably use this /// in all tests that you don't expect to throw any errors, including /// UserExceptions. /// /// On the contrary, when you are actually testing that some code throws /// specific UserExceptions, you should NOT use this error observer, but /// should instead let the UserExceptions go silently to the error queue /// (the `errors` field in the store), and then assert that the queue /// actually contains those errors. /// class TestErrorObserver implements ErrorObserver { @override bool observe( Object error, StackTrace stackTrace, ReduxAction action, Store store, ) => true; } class _NewStateAction extends ReduxAction { final St newState; _NewStateAction(this.newState); @override St reduce() => newState; } ================================================ FILE: lib/src/test_info.dart ================================================ import 'dart:collection'; import 'package:async_redux/async_redux.dart'; typedef TestInfoPrinter = void Function(TestInfo); class TestInfo { final St state; final bool ini; final ReduxAction? action; final int dispatchCount; final int reduceCount; /// List of all UserException's waiting to be displayed in the error dialog. Queue errors; /// The error thrown by the action, if any, /// before being processed by the action's wrapError() method. final Object? error; /// The error thrown by the action, /// after being processed by the action's wrapError() method. final Object? processedError; bool get isINI => ini; bool get isEND => !ini; Type get type { // Removes the generic type from UserExceptionAction, WaitAction, // NavigateAction and PersistAction. // For example UserExceptionAction becomes UserExceptionAction. if (action is UserExceptionAction) { if (action.runtimeType.toString().split('<')[0] == 'UserExceptionAction') // return UserExceptionAction; } else if (action is WaitAction) { if (action.runtimeType.toString().split('<')[0] == 'WaitAction') // return WaitAction; } else if (action is NavigateAction) { if (action.runtimeType.toString().split('<')[0] == 'NavigateAction') // return NavigateAction; } else if (action is PersistAction) { if (action.runtimeType.toString().split('<')[0] == 'PersistAction') // return PersistAction; } return action.runtimeType; } TestInfo( this.state, this.ini, this.action, this.error, this.processedError, this.dispatchCount, this.reduceCount, this.errors, ) : assert(state != null); @override String toString() => 'D:$dispatchCount ' 'R:$reduceCount ' '= $action ${ini ? "INI" : "END"}\n'; } ================================================ FILE: lib/src/user_exception_dialog.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'dart:collection'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'show_dialog_super.dart'; /// Use it like this: /// /// ``` /// class MyApp extends StatelessWidget { /// @override /// Widget build(BuildContext context) /// => StoreProvider( /// store: store, /// child: MaterialApp( /// home: UserExceptionDialog( /// child: MyHomePage(), /// ))); /// } /// /// ``` /// /// For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// class UserExceptionDialog extends StatelessWidget { final Widget child; final ShowUserExceptionDialog? onShowUserExceptionDialog; /// If false (the default), the dialog will appear in the context of the /// [NavigateAction.navigatorKey]. If you don't set up that key, or if you /// pass `true` here, it will use the local context of the /// [UserExceptionDialog] widget. /// /// Make sure this is `false` if you are putting the [UserExceptionDialog] in /// the `builder` parameter of the [MaterialApp] widget, because in this case /// the [UserExceptionDialog] will be above the app's [Navigator], and if /// you open the dialog in the local context you won't be able to use the /// Android back-button to close it. final bool useLocalContext; UserExceptionDialog({ required this.child, this.onShowUserExceptionDialog, this.useLocalContext = false, }); @override Widget build(BuildContext context) { // return StoreConnector( vm: () => _Factory(), builder: (context, vm) { // Event? errorEvent = // (_Factory._errorEvents.isEmpty) // ? null : _Factory._errorEvents.removeFirst(); return _UserExceptionDialogWidget( child, errorEvent, onShowUserExceptionDialog, useLocalContext, ); }, ); } } class _UserExceptionDialogWidget extends StatefulWidget { final Widget child; final Event? errorEvent; final ShowUserExceptionDialog onShowUserExceptionDialog; final bool useLocalContext; _UserExceptionDialogWidget( this.child, this.errorEvent, ShowUserExceptionDialog? onShowUserExceptionDialog, this.useLocalContext, ) : onShowUserExceptionDialog = // onShowUserExceptionDialog ?? _defaultUserExceptionDialog; static void _defaultUserExceptionDialog( BuildContext context, UserException userException, bool useLocalContext, ) { if (!useLocalContext) { var navigatorContext = NavigateAction.navigatorKey?.currentContext; if (navigatorContext != null) context = navigatorContext; } defaultTargetPlatform; if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS)) { showCupertinoDialogSuper( context: context, onDismissed: (int? result) { if (result == 1) userException.onOk?.call(); else if (result == 2) userException.onCancel?.call(); else { if (userException.onCancel == null) userException.onOk?.call(); else userException.onCancel?.call(); } }, builder: (BuildContext context) { var (title, content) = userException.titleAndContent(); return CupertinoAlertDialog( title: Text(title), content: Text(content), actions: [ CupertinoDialogAction( child: const Text("OK"), onPressed: () { Navigator.of(context).pop(1); }, ), if (userException.onCancel != null) CupertinoDialogAction( child: const Text("CANCEL"), onPressed: () { Navigator.of(context).pop(2); }, ) ], ); }, ); } else showDialogSuper( context: context, onDismissed: (int? result) { if (result == 1) userException.onOk?.call(); else if (result == 2) userException.onCancel?.call(); else { if (userException.onCancel == null) userException.onOk?.call(); else userException.onCancel?.call(); } }, builder: (BuildContext context) { var (title, content) = userException.titleAndContent(); return AlertDialog( title: Text(title), content: Text(content), actions: [ if (userException.onCancel != null) TextButton( child: const Text("CANCEL"), onPressed: () { Navigator.of(context).pop(2); }, ), TextButton( child: const Text("OK"), onPressed: () { Navigator.of(context).pop(1); }, ) ], ); }, ); } @override _UserExceptionDialogState createState() => _UserExceptionDialogState(); } class _UserExceptionDialogState extends State<_UserExceptionDialogWidget> { @override void didUpdateWidget(_UserExceptionDialogWidget oldWidget) { super.didUpdateWidget(oldWidget); UserException? userException = widget.errorEvent?.consume(); if (userException != null) WidgetsBinding.instance.addPostFrameCallback((_) { widget.onShowUserExceptionDialog( context, userException, widget.useLocalContext); }); } @override Widget build(BuildContext context) => widget.child; } class _Factory extends VmFactory { static final Queue> _errorEvents = Queue(); @override _Vm fromStore() { UserException? error = getAndRemoveFirstError(); if (error != null) _errorEvents.add(Event(error)); return _Vm( rebuild: (error != null), ); } } class _Vm extends Vm { // final bool rebuild; _Vm({required this.rebuild}); /// Does not respect equals contract: /// Is not equal when it should rebuild. @override bool operator ==(Object other) => !rebuild; @override int get hashCode => rebuild.hashCode; } typedef ShowUserExceptionDialog = void Function( BuildContext context, UserException userException, bool useLocalContext, ); ================================================ FILE: lib/src/view_model.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux library async_redux_view_model; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; /// Each state passed in the [Vm.equals] parameter in the in view-model will be /// compared by equality (==), unless it is of type [VmEquals], when it will be /// compared by the [VmEquals.vmEquals] method, which by default is a comparison /// by identity (but can be overridden). abstract class VmEquals { bool vmEquals(T other) => identical(this, other); } /// [Vm] is a base class for your view-models. /// /// A view-model is a helper object to a [StoreConnector] widget. It holds the /// part of the Store state the corresponding dumb-widget needs, and may also /// convert this state part into a more convenient format for the dumb-widget /// to work with. /// /// Each time the state changes, all [StoreConnector]s in the widget tree will /// create a view-model, and compare it with the view-model they created with /// the previous state. Only if the view-model changed, the [StoreConnector] /// will rebuild. For this to work, you must implement equals/hashcode for the /// view-model class. Otherwise, the [StoreConnector] will think the view-model /// changed everytime, and thus will rebuild everytime. This wouldn't create any /// visible problems to your app, but would be inefficient and maybe slow. /// /// Using the [Vm] class you can implement equals/hashcode without having to /// override these methods. Instead, simply list all fields (which are not /// immutable, like functions) to the [equals] parameter in the constructor. /// For example: /// /// ``` /// ViewModel({this.counter, this.onIncrement}) : super(equals: [counter]); /// ``` /// /// Each listed state will be compared by equality (==), unless it is of type /// [VmEquals], when it will be compared by the [VmEquals.vmEquals] method, /// which by default is a comparison by identity (but can be overridden). /// @immutable abstract class Vm { // /// To test the view-model generated by a Factory, use [createFrom] and pass it the /// [store] and the [factory]. Note this method must be called in a recently /// created factory, as it can only be called once per factory instance. /// /// The method will return the view-model, which you can use to: /// /// * Inspect the view-model properties directly, or /// /// * Call any of the view-model callbacks. If the callbacks dispatch actions, /// you use `await store.waitActionType(MyAction)`, /// or `await store.waitAllActionTypes([MyAction, OtherAction])`, /// or `await store.waitCondition((state) => ...)`, or if necessary you can even /// record all dispatched actions and state changes with `Store.record.start()` /// and `Store.record.stop()`. /// /// Example: /// ``` /// var store = Store(initialState: User("Mary")); /// var vm = Vm.createFrom(store, MyFactory()); /// /// // Checking a view-model property. /// expect(vm.user.name, "Mary"); /// /// // Calling a view-model callback and waiting for the action to finish. /// vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). /// await store.waitActionType(SetNameAction); /// expect(store.state.name, "Bill"); /// /// // Calling a view-model callback and waiting for the state to change. /// vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). /// await store.waitCondition((state) => state.name == "Bill"); /// expect(store.state.name, "Bill"); /// ``` /// @visibleForTesting static Model createFrom( Store store, VmFactory factory, ) { internalsVmFactoryInject(factory, store.state, store); return internalsVmFactoryFromStore(factory) as Model; } /// The List of properties which will be used to determine whether two BaseModels are equal. final List equals; /// The constructor takes an optional List of fields which will be used /// to determine whether two [Vm] are equal. Vm({this.equals = const []}) : assert(_onlyContainFieldsOfAllowedTypes(equals)); /// Fields should not contain functions. static bool _onlyContainFieldsOfAllowedTypes(List equals) { equals.forEach((Object? field) { if (field is Function) throw StoreException("ViewModel equals " "can't contain field of type Function: ${field.runtimeType}."); }); return true; } @override bool operator ==(Object other) { return identical(this, other) || other is Vm && runtimeType == other.runtimeType && _listEquals( equals, other.equals, ); } bool _listEquals(List? list1, List? list2) { if (list1 == null) return list2 == null; if (list2 == null || list1.length != list2.length) return false; if (identical(list1, list2)) return true; for (int index = 0; index < list1.length; index++) { var item1 = list1[index]; var item2 = list2[index]; if ((item1 is VmEquals) && (item2 is VmEquals) // && !item1.vmEquals(item2)) return false; if (item1 != item2) return false; } return true; } @override int get hashCode => runtimeType.hashCode ^ _propsHashCode; int get _propsHashCode { int hashCode = 0; equals.forEach((Object? prop) => hashCode = hashCode ^ prop.hashCode); return hashCode; } @override String toString() => '$runtimeType{${equals.join(', ')}}'; } /// Factory that creates a view-model of type [Vm], for the [StoreConnector]: /// /// ``` /// return StoreConnector( /// vm: _Factory(), /// builder: ... /// ``` /// /// You must override the [fromStore] method: /// /// ``` /// class _Factory extends VmFactory { /// _ViewModel fromStore() => _ViewModel( /// counter: state, /// onIncrement: () => dispatch(IncrementAction(amount: 1))); /// } /// ``` /// /// If necessary, you can pass the [StoreConnector] widget to the factory: /// /// ``` /// return StoreConnector( /// vm: _Factory(this), /// builder: ... /// /// ... /// class _Factory extends VmFactory { /// _Factory(connector) : super(connector); /// _ViewModel fromStore() => _ViewModel( /// counter: state, /// onIncrement: () => dispatch(IncrementAction(amount: widget.amount))); /// } /// ``` /// abstract class VmFactory { /// You need to pass the connector widget only if the view-model needs any info from it. VmFactory([this._connector]); Model? fromStore(); final T? _connector; /// The connector widget that will instantiate the view-model. @Deprecated("Use `connector` instead") T? get widget => _connector; /// The connector widget that will instantiate the view-model. T get connector { if (_connector == null) throw StoreException( "To use the `connector` field you must pass it to the factory constructor:" "\n\n" "return StoreConnector(\n" " vm: () => Factory(this),\n" " ..." "\n\n" "class Factory extends VmFactory<_Vm, MyConnector> {\n" " Factory(Widget widget) : super(widget);"); else return _connector; } late final Store _store; late final St _state; /// Once the Vm is created, we save it so that it can be used by factory methods. Model? _vm; bool _vmCreated = false; /// Once the view-model is created, and as long as it's not null, you can reference /// it by using the [vm] getter. This is meant to be used inside of Factory methods. /// /// Example: /// /// ``` /// ViewModel fromStore() => /// ViewModel( /// value: _calculateValue(), /// onTap: _onTap); /// } /// /// // Here we use the value, without having to recalculate it. /// void _onTap() => dispatch(SaveValueAction(vm.value)); /// ``` /// Model get vm { if (!_vmCreated) throw StoreException("You can't reference the view-model " "before it's created and returned by the fromStore method."); if (_vm == null) throw StoreException("You can't reference the view-model, " "because it's null."); return _vm!; } bool get ifVmIsNull { if (!_vmCreated) throw StoreException("You can't reference the view-model " "before it's created and returned by the fromStore method."); return (_vm == null); } void _setStore(St state, Store store) { _store = store as Store; _state = state; } /// The state the store was holding when the factory and the view-model were created. /// This state is final inside of the factory. St get state => _state; /// Gets a property from the store. /// This can be used to save global values, but scoped to the store. /// For example, you could save timers, streams or futures used by actions. /// /// ```dart /// setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); /// var timer = prop("timer"); /// timer.cancel(); /// ``` /// /// See also: [setProp]. V prop(Object? key) => _store.prop(key); /// Sets a property in the store. /// This can be used to save global values, but scoped to the store. /// For example, you could save timers, streams or futures used by actions. /// /// ```dart /// setProp("timer", Timer(Duration(seconds: 1), () => print("tick"))); /// var timer = prop("timer"); /// timer.cancel(); /// ``` /// /// See also: [prop] and [env]. void setProp(Object? key, Object? value) => _store.setProp(key, value); /// The current (most recent) store state. /// This will return the current state the store holds at the time the method is called. St currentState() => _store.state; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. /// /// ```dart /// store.dispatch(MyAction()); /// ``` /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatch] is of type [Dispatch]. /// /// See also: /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// Dispatch get dispatch => _store.dispatch; @Deprecated("Use `dispatchAndWait` instead. This will be removed.") DispatchAsync get dispatchAsync => _store.dispatchAndWait; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// The action may be sync or async. In both cases, it returns a [Future] that resolves when /// the action finishes. /// /// ```dart /// await store.dispatchAndWait(DoThisFirstAction()); /// store.dispatch(DoThisSecondAction()); /// ``` /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Note: While the state change from the action's reducer will have been applied when the /// Future resolves, other independent processes that the action may have started may still /// be in progress. /// /// Method [dispatchAndWait] is of type [DispatchAndWait]. It returns `Future`, /// which means you can also get the final status of the action after you `await` it: /// /// ```dart /// var status = await store.dispatchAndWait(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchSync] which dispatches sync actions, and throws if the action is async. /// DispatchAndWait get dispatchAndWait => _store.dispatchAndWait; /// Dispatches the action, applying its reducer, and possibly changing the store state. /// However, if the action is ASYNC, it will throw a [StoreException]. /// /// If you pass the [notify] parameter as `false`, widgets will not necessarily rebuild because /// of this action, even if it changes the state. /// /// Method [dispatchSync] is of type [DispatchSync]. It returns `ActionStatus`, /// which means you can also get the final status of the action: /// /// ```dart /// var status = store.dispatchSync(MyAction()); /// ``` /// /// See also: /// - [dispatch] which dispatches both sync and async actions. /// - [dispatchAndWait] which dispatches both sync and async actions, and returns a Future. /// DispatchSync get dispatchSync => _store.dispatchSync; /// You can use [isWaiting] to check if: /// * A specific async ACTION is currently being processed. /// * An async action of a specific TYPE is currently being processed. /// * If any of a few given async actions or action types is currently being processed. /// /// If you wait for an action TYPE, then it returns false when: /// - The ASYNC action of type [actionType] is NOT currently being processed. /// - If [actionType] is not really a type that extends [ReduxAction]. /// - The action of type [actionType] is a SYNC action (since those finish immediately). /// /// If you wait for an ACTION, then it returns false when: /// - The ASYNC [action] is NOT currently being processed. /// - If [action] is a SYNC action (since those finish immediately). // /// Examples: /// /// ```dart /// // Waiting for an action TYPE: /// dispatch(MyAction()); /// if (isWaiting(MyAction)) { // Show a spinner } /// /// // Waiting for an ACTION: /// var action = MyAction(); /// dispatch(action); /// if (isWaiting(action)) { // Show a spinner } /// /// // Waiting for any of the given action TYPES: /// dispatch(BuyAction()); /// if (isWaiting([BuyAction, SellAction])) { // Show a spinner } /// ``` bool isWaiting(Object actionOrTypeOrList) => _store.isWaiting(actionOrTypeOrList); /// Returns true if an [actionOrTypeOrList] failed with an [UserException]. /// Note: This method uses the EXACT type in [actionOrTypeOrList]. Subtypes are not considered. bool isFailed(Object actionOrTypeOrList) => _store.isFailed(actionOrTypeOrList); /// Returns the [UserException] of the [actionTypeOrList] that failed. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Note: This method uses the EXACT type in [actionTypeOrList]. Subtypes are not considered. UserException? exceptionFor(Object actionTypeOrList) => _store.exceptionFor(actionTypeOrList); /// Removes the given [actionTypeOrList] from the list of action types that failed. /// /// Note that dispatching an action already removes that action type from the exceptions list. /// This removal happens as soon as the action is dispatched, not when it finishes. /// /// [actionTypeOrList] can be a [Type], or an Iterable of types. Any other type /// of object will return null and throw a [StoreException] after the async gap. /// /// Note: This method uses the EXACT type in [actionTypeOrList]. Subtypes are not considered. void clearExceptionFor(Object actionTypeOrList) => _store.clearExceptionFor(actionTypeOrList); /// Returns a future which will complete when the given state [condition] is true. /// If the condition is already true when the method is called, the future completes immediately. /// /// You may also provide a [timeoutMillis], which by default is 10 minutes. /// To disable the timeout, make it -1. /// If you want, you can modify [Store.defaultTimeoutMillis] to change the default timeout. /// /// ```dart /// var action = await store.waitCondition((state) => state.name == "Bill"); /// expect(action, isA()); /// ``` Future?> waitCondition( bool Function(St) condition, { int? timeoutMillis, }) => _store.waitCondition(condition, timeoutMillis: timeoutMillis); /// Returns a future that completes when ALL given [actions] finished dispatching. /// You MUST provide at list one action, or an error will be thrown. /// /// If [completeImmediately] is `false` (the default), this method will throw [StoreException] /// if none of the given actions are in progress when the method is called. Otherwise, the future /// will complete immediately and throw no error. /// /// Example: /// /// ```ts /// // Dispatching two actions in PARALLEL and waiting for both to finish. /// var action1 = ChangeNameAction('Bill'); /// var action2 = ChangeAgeAction(42); /// await waitAllActions([action1, action2]); /// /// // Compare this to dispatching the actions in SERIES: /// await dispatchAndWait(action1); /// await dispatchAndWait(action2); /// ``` Future waitAllActions(List> actions, {bool completeImmediately = false}) { if (actions.isEmpty) throw StoreException('You have to provide a non-empty list of actions.'); return _store.waitAllActions(actions, completeImmediately: completeImmediately); } /// Gets the first error from the error queue, and removes it from the queue. UserException? getAndRemoveFirstError() => _store.getAndRemoveFirstError(); } /// For internal use only. Please don't use this. Vm? internalsVmFactoryFromStore( VmFactory vmFactory) { vmFactory._vm = vmFactory.fromStore(); vmFactory._vmCreated = true; return vmFactory._vm; } /// For internal use only. Please don't use this. void internalsVmFactoryInject( VmFactory vmFactory, St state, Store store) { vmFactory._setStore(state, store); } ================================================ FILE: lib/src/wait.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:flutter/foundation.dart'; enum WaitOperation { add, remove, clear } /// Immutable object to keep track of boolean flags that indicate if some /// process is in progress (the user is "waiting"). /// /// The flags and flag-references can be any immutable object. /// They must be immutable to make sure [Wait] is also immutable. /// /// Use it in Redux store states, like this: /// * To add a flag: state.copy(wait: state.wait.add(flag: myFlag)); /// * To remove a flag: state.copy(wait: state.wait.remove(flag: myFlag)); /// * To clear all flags: state.copy(wait: state.wait.clear()); /// /// If can also use have a flag with a reference, like this: /// * To add a flag with reference: state.copy(wait: state.wait.add(flag: myFlag, ref:MyRef)); /// * To remove a flag with reference: state.copy(wait: state.wait.remove(flag: myFlag, ref:MyRef)); /// * To clear all references for a flag: state.copy(wait: state.wait.clear(flag: myFlag)); /// /// In the ViewModel, you can check the flags/references, like this: /// /// * To check if there is any waiting: state.wait.isWaitingAny /// * To check if is waiting a specific flag: state.wait.isWaiting(myFlag); /// * To check if is waiting a specific flag/reference: state.wait.isWaiting(myFlag, ref: myRef); /// @immutable class Wait { final Map> _flags; static const Wait empty = Wait._({}); factory Wait() => empty; /// Convenience flag that you can use when a `null` value means ALL. /// For example, suppose if you want until an async process schedules an `appointment` /// for specific `time`. However, if no time is selected, you want to schedule the whole /// day (all "times"). You can do: /// `dispatch(WaitAction.add(appointment, ref: time ?? Wait.ALL));` /// /// And then later check if you are waiting for a specific time: /// `if (wait.isWaiting(appointment, ref: time) { ... }` /// /// Or if you are waiting for the whole day: /// `if (wait.isWaiting(appointment, ref: Wait.ALL) { ... }` /// static const ALL = Object(); const Wait._(Map> flags) : _flags = flags; Wait add({required Object? flag, Object? ref}) { Map> newFlags = _deepCopy(); Set? refs = newFlags[flag]; if (refs == null) { refs = {}; newFlags[flag] = refs; } refs.add(ref); return Wait._(newFlags); } Wait remove({required Object? flag, Object? ref}) { if (_flags.isEmpty) return this; else { Map> newFlags = _deepCopy(); if (ref == null) { newFlags.remove(flag); } else { Set refs = newFlags[flag] ?? {}; refs.remove(ref); if (refs.isEmpty) newFlags.remove(flag); } if (newFlags.isEmpty) return empty; else return Wait._(newFlags); } } Wait process( WaitOperation operation, { required Object? flag, Object? ref, }) { if (operation == WaitOperation.add) return add(flag: flag, ref: ref); else if (operation == WaitOperation.remove) return remove(flag: flag, ref: ref); else if (operation == WaitOperation.clear) return clear(flag: flag); else throw AssertionError(operation); } /// Return true if there is any waiting (any flag). bool get isWaitingAny => _flags.isNotEmpty; /// Return true if is waiting for a specific flag. /// If [ref] is null, it returns true if it's waiting for any reference of the flag. /// If [ref] is not null, it returns true if it's waiting for that specific reference of the flag. bool isWaiting(Object? flag, {Object? ref}) { Set? refs = _flags[flag]; return (ref == null) // ? (refs != null) && refs.isNotEmpty // : (refs != null) && refs.contains(ref); } /// Return true if is waiting for ANY flag of the specific type. /// /// This is useful when you want to wait for an Action to finish. For example: /// /// ``` /// class MyAction extends ReduxAction { /// Future reduce() async { /// await doSomething(); /// return null; /// } /// /// void before() => dispatch(WaitAction.add(this)); /// void after() => dispatch(WaitAction.remove(this)); /// } /// /// // Then, in some widget or connector: /// if (wait.isWaitingForType()) { ... } /// ``` bool isWaitingForType() { for (Object? flag in _flags.keys) if (flag is T) return true; return false; } Wait clear({Object? flag}) { if (flag == null) return empty; else { Map> newFlags = _deepCopy(); newFlags.remove(flag); return Wait._(newFlags); } } void clearWhere( bool Function( Object? flag, Set refs, ) test) => _flags.removeWhere(test); Map> _deepCopy() { Map> newFlags = {}; for (var MapEntry(key: key, value: value) in _flags.entries) { newFlags[key] = Set.of(value); } return newFlags; } } ================================================ FILE: lib/src/wait_action.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:flutter/cupertino.dart'; import '../async_redux.dart'; /// [WaitAction] and [Wait] work together to help you create boolean flags that /// indicate some process is currently running. For this to work your store state /// must have a `Wait` field named `wait`, and then: /// /// 1) The state must have a `copy` or `copyWith` method that copies this /// field as a named parameter. For example: /// /// ``` /// class AppState { /// final Wait wait; /// AppState({this.wait}); /// AppState copy({Wait wait}) => AppState(wait: wait); /// } /// ``` /// /// OR: /// /// 2) You must use the BuiltValue package https://pub.dev/packages/built_value, /// which automatically creates a `rebuild` method. /// /// OR: /// /// 3) You must use the Freezed package https://pub.dev/packages/freezed, /// which automatically creates the `copyWith` method. /// /// OR: /// /// 4) Inject your own [WaitReducer] implementation into [WaitAction] /// by replacing the static variable [WaitAction.reducer] with a callback /// that changes the wait object as you see fit. /// /// OR: /// /// 5) Don't use the [WaitAction], but instead create your own `MyWaitAction` /// that uses the [Wait] object in whatever way you want. /// class WaitAction extends ReduxAction { // /// Works out-of-the-box for most use cases, but you can inject your /// own reducer here during your app's initialization, if necessary. static WaitReducer reducer = _defaultReducer; /// The default is to choose a reducer that is compatible with your AppState class. static final WaitReducer _defaultReducer = ( state, operation, flag, ref, ) { try { return _copyReducer(state, operation, flag, ref); } on NoSuchMethodError catch (_) { try { return _builtValueReducer(state, operation, flag, ref); } on NoSuchMethodError catch (_) { try { return _freezedReducer(state, operation, flag, ref); } on NoSuchMethodError catch (_) { throw AssertionError("The store state " "is not compatible with WaitAction."); } } } }; /// For this to work, your state class must have a [copy] method. static final WaitReducer _copyReducer = (state, operation, flag, ref) { Wait wait = (state as dynamic).wait ?? Wait(); return (state as dynamic).copy( wait: wait.process( operation, flag: flag, ref: ref, )); }; /// For this to work, your state class must have a suitable [rebuild] method. /// This happens automatically when you use the BuiltValue package. static final WaitReducer _builtValueReducer = (state, operation, flag, ref) { Wait wait = (state as dynamic).wait ?? Wait(); return (state as dynamic).rebuild((state) => state ..wait = wait.process( operation, flag: flag, ref: ref, )); }; /// For this to work, your state class must have a [copyWith] method. /// This happens automatically when you use the Freezed package. static final WaitReducer _freezedReducer = (state, operation, flag, ref) { Wait wait = (state as dynamic).wait ?? Wait(); return (state as dynamic).copyWith( wait: wait.process( operation, flag: flag, ref: ref, )); }; final WaitOperation operation; final Object? flag, ref; final Duration? delay; /// Adds a [flag] that indicates some process is currently running. /// Optionally, you can also have a flag-reference called [ref]. /// /// Note: [flag] and [ref] must be immutable objects. /// /// ``` /// // Add a wait state, using this as the flag. /// dispatch(WaitAction.add(this)); /// /// // Add a wait state, using this as the flag, and 123 as a reference. /// dispatch(WaitAction.add(this, ref: 123)); /// ``` /// Note: When the process finishes running, you will have to remove /// the [flag] by using the [remove] or [clear] methods. /// /// If you pass a [delay], the flag will be added only after that /// duration has passed, after the [add] method is called. /// WaitAction.add( this.flag, { this.ref, this.delay, }) : operation = WaitOperation.add; /// Removes a [flag] previously added with the [add] method. /// Removing the flag indicating some process finished running. /// /// If you added the flag with a reference [ref], you must also pass the /// same reference here to remove it. Alternatively, if you want to /// remove all references to that flag, use the [clear] method instead. /// /// ``` /// // Add and remove a wait state, using this as the flag. /// dispatch(WaitAction.add(this)); /// dispatch(WaitAction.remove(this)); /// /// // Adds and remove a wait state, using this as the flag, and 123 as a reference. /// dispatch(WaitAction.add(this, ref: 123)); /// dispatch(WaitAction.remove(this, ref: 123)); /// ``` /// /// If you pass a [delay], the flag will be removed only after that /// duration has passed, after the [add] method is called. Example: /// /// ``` /// // Add a wait state that will be automatically removed after 3 seconds. /// dispatch(WaitAction.add(this)); /// dispatch(WaitAction.remove(this, delay: Duration(seconds: 3))); /// ``` /// WaitAction.remove( this.flag, { this.ref, this.delay, }) : operation = WaitOperation.remove; /// Clears (removes) the [flag], with all its references. /// Removing the flag indicating some process finished running. /// /// ``` /// dispatch(WaitAction.add(this, flag: 123)); /// dispatch(WaitAction.add(this, flag: "xyz")); /// dispatch(WaitAction.clear(this); /// ``` WaitAction.clear([ this.flag, ]) : operation = WaitOperation.clear, delay = null, ref = null; @override St? reduce() { if (delay == null) return reducer(state, operation, flag, ref); else { Future.delayed(delay!, () { reducer(state, operation, flag, ref); }); return null; } } @override String toString() => 'WaitAction.${operation.name}(' 'flag: ${flag.toStringLimited()}, ' 'ref: ${ref.toStringLimited()})'; } typedef WaitReducer = St? Function( St? state, WaitOperation operation, Object? flag, Object? ref, ); extension _StringExtension on Object? { /// If the object can be represented with up to 50 chars, we print it. /// Otherwise, we cut the text (using the Characters lib) and add an ellipsis. String toStringLimited() { String text = toString(); return (text.length <= 50) ? text : "${Characters(text).take(49)}…"; } } ================================================ FILE: lib/src/wrap_reduce.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // Based upon packages redux by Brian Egan, and flutter_redux by Brian Egan and John Ryan. // Uses code from package equatable by Felix Angelov. // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; /// You may globally wrap the reducer to allow for some pre or post-processing. /// Note: if the action also have a [ReduxAction.wrapReduce] method, this global /// wrapper will be called AFTER (it will wrap the action's wrapper which wraps /// the action's reducer). /// /// If [ifShouldProcess] is overridden to return `false`, the wrapper will /// be turned of. /// /// The [process] method gets the old-state and the new-state, and returns /// the end state that you want to send to the store. Note: In sync reducers, /// the old-state is the state before the reducer is called. However, in /// async reducers, the old-state is the state AFTER the reducer returns /// but before the reducer's result is committed to the store. /// /// For example, this wrapper checks if `newState.someInfo` is out of range, /// and if that's the case it's logged and changed to some valid value: /// /// ``` /// class MyWrapReduce extends WrapReduce { /// St process({required St oldState, required St newState}) { /// if (identical(newState.someInfo, oldState.someInfo) || oldState.someInfo.isWithRange()) /// return newState; /// else { /// Logger.log('Invalid value: ${oldState.someInfo}'); /// return newState.copy(someInfo: newState.someInfo.copy(SomeInfo(validValue))); /// }}} /// ``` /// /// Note the [wrapReduce] method encapsulates the complexities of /// differentiating sync and async reducers. However, you can override it /// to provide your own implementation if necessary. /// abstract class WrapReduce { // bool ifShouldProcess() => true; St process({ required St oldState, required St newState, }); Reducer wrapReduce( Reducer reduce, Store store, ) { // if (!ifShouldProcess()) return reduce; // // 1) Sync reducer. else { if (reduce is St? Function()) { return () { // // The old-state right before calling the sync reducer. St oldState = store.state; // This is the state returned by the reducer. St? newState = reduce(); // If the reducer returned null, or the same instance, do nothing. if (newState == null || identical(store.state, newState)) return newState; return process(oldState: oldState, newState: newState); }; } // // 2) Async reducer. else if (reduce is Future Function()) { return () async { // // The is the state returned by the reducer. St? newState = await reduce(); // This is the state right after the reducer returns, // but before it's committed to the store. St oldState = store.state; // If the reducer returned null, or returned the same instance, don't do anything. if (newState == null || identical(store.state, newState)) return newState; return process(oldState: oldState, newState: newState); }; } // Not defined. else { throw StoreException("Reducer should return `St?` or `Future`. " "Do not return `FutureOr`. " "Reduce is of type: '${reduce.runtimeType}'."); } } } } ================================================ FILE: mixin_compatibility.md ================================================ # Mixin Compatibility Matrix This document describes the compatibility between AsyncRedux action mixins. ## Mixins Overview | Mixin | Purpose | Overrides | |-------------------------------|---------------------------------------------------------------------------|-------------------------------| | `CheckInternet` | Checks internet before action; shows dialog if no connection | `before` | | `NoDialog` | Modifier for `CheckInternet` to suppress dialog | (requires `CheckInternet`) | | `AbortWhenNoInternet` | Checks internet before action; aborts silently if no connection | `before` | | `NonReentrant` | Aborts if the same action is already running | `abortDispatch` | | `Retry` | Retries the action on error with exponential backoff | `wrapReduce` | | `UnlimitedRetries` | Modifier for `Retry` to retry indefinitely | (requires `Retry`) | | `OptimisticCommand` | Applies state changes optimistically, rolls back on error | `reduce` | | `OptimisticSync` | Optimistic updates with coalescing; merges rapid dispatches into one sync | `reduce` | | `OptimisticSyncWithPush` | Like `OptimisticSync` but with revision tracking for server pushes | `reduce` | | `ServerPush` | Handles server-pushed updates for `OptimisticSyncWithPush` | `reduce` | | `Throttle` | Limits action execution to at most once per throttle period | `abortDispatch`, `after` | | `Debounce` | Delays execution until after a period of inactivity | `wrapReduce` | | `UnlimitedRetryCheckInternet` | Combines internet check + unlimited retry + non-reentrant | `abortDispatch`, `wrapReduce` | | `Fresh` | Skips action if data is still fresh (not stale) | `abortDispatch`, `after` | | `Polling` | Adds periodic polling to any action | `wrapReduce` | ## Compatibility Matrix | | CheckInternet | NoDialog | AbortWhenNoInternet | NonReentrant | Retry | UnlimitedRetries | UnlimitedRetryCheckInternet | Throttle | Debounce | Fresh | OptimisticCommand | OptimisticSync | OptimisticSyncWithPush | ServerPush | Polling | |---------------------------------|:-------------:|:--------:|:-------------------:|:------------:|:-----:|:----------------:|:---------------------------:|:--------:|:--------:|:-----:|:-----------------:|:--------------:|:----------------------:|:----------:|:-------:| | **CheckInternet** | — | ✅ | ❌ | ✅ | ✅️ | ✅️ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | **NoDialog** | ➡️ | — | ❌ | ✅ | ✅️ | ✅️ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | **AbortWhenNoInternet** | ❌ | ❌ | — | ✅ | ✅️ | ✅️ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | **NonReentrant** | ✅ | ✅ | ✅ | — | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | **Retry** | ✅️ | ✅️ | ✅️ | ✅ | — | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | **UnlimitedRetries** | ✅️ | ✅️ | ✅️ | ✅ | ➡️ | — | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **UnlimitedRetryCheckInternet** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Throttle** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | — | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | | **Debounce** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | — | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Fresh** | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | — | ❌ | ❌ | ❌ | ❌ | ✅ | | **OptimisticCommand** | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ | ❌ | | **OptimisticSync** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ | | **OptimisticSyncWithPush** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ | | **ServerPush** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | | **Polling** | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | — | - ✅ = Compatible (can be combined) - ❌ = Incompatible (cannot be combined) - ➡️ = Requires (must be used together) ## Incompatibility Groups ### Group 1: Internet Checking Mixins These mixins all check internet connectivity and cannot be combined with each other: - `CheckInternet` - `AbortWhenNoInternet` - `UnlimitedRetryCheckInternet` ### Group 2: abortDispatch Mixins These mixins override `abortDispatch` and cannot be combined with each other: - `NonReentrant` - `Throttle` - `UnlimitedRetryCheckInternet` - `Fresh` ### Group 3: wrapReduce Mixins These mixins override `wrapReduce` and cannot be combined with each other: - `Retry` / `UnlimitedRetries` - `Debounce` - `UnlimitedRetryCheckInternet` - `Polling` ### Group 4: Optimistic Update Mixins These mixins handle optimistic state updates and cannot be combined with each other: - `OptimisticCommand` - `OptimisticSync` - `OptimisticSyncWithPush` - `ServerPush` (used alongside `OptimisticSyncWithPush`, but not combined with it in the same action) ## Notes ### CheckInternet / AbortWhenNoInternet + Retry Combining `Retry` with `CheckInternet` or `AbortWhenNoInternet` will not retry when there is no internet. It will only retry if there **is** internet but the action fails for some other reason. To retry indefinitely until internet is available, use `UnlimitedRetryCheckInternet` instead. ### NoDialog `NoDialog` is a modifier mixin that **requires** `CheckInternet`. It cannot be used alone: ```dart class MyAction extends ReduxAction with CheckInternet, NoDialog { ... } ``` ### UnlimitedRetries `UnlimitedRetries` is a modifier mixin that **requires** `Retry`. It cannot be used alone: ```dart class MyAction extends ReduxAction with Retry, UnlimitedRetries { ... } ``` ### Recommended Combinations - `Retry` + `NonReentrant`: Recommended to avoid multiple instances running simultaneously. - `CheckInternet` + `NonReentrant`: Safe combination for internet-dependent actions. - `CheckInternet` + `Throttle`: Safe combination (but not with `NonReentrant` at the same time) - `AbortWhenNoInternet` + `NonReentrant`: Safe combination. - `AbortWhenNoInternet` + `Throttle`: Safe combination (but not with `NonReentrant` at the same time) ================================================ FILE: pubspec.yaml ================================================ name: async_redux description: The modern version of Redux. State management that's simple to learn and easy to use; Powerful enough to handle complex applications with millions of users; Testable. version: 28.0.0-dev.3 # author: Marcelo Glasberg repository: https://github.com/marcglasberg/async_redux issue_tracker: https://github.com/marcglasberg/async_redux/issues homepage: https://asyncredux.com documentation: https://asyncredux.com topics: - redux - state-management - ui - reactive-programming - testing environment: sdk: '>=3.5.0 <4.0.0' flutter: ">=3.16.0" dependencies: async_redux_core: ^1.4.1 fast_immutable_collections: ^11.1.0 weak_map: ^4.0.1 connectivity_plus: ">=6.0.3 <8.0.0" collection: ^1.18.0 logging: ^1.3.0 path: ^1.9.1 file: ^7.0.0 path_provider: ^2.1.5 meta: ^1.11.0 flutter: sdk: flutter dev_dependencies: flutter_test: sdk: flutter fake_async: ^1.3.3 bdd_framework: ^4.0.6 ================================================ FILE: test/abort_dispatch_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux List? info; void main() { var feature = BddFeature('Abort dispatch of actions'); test('Test aborting an action.', () async { // info = []; Store store = Store(initialState: ""); store.dispatch(ActionA(abort: false)); expect(store.state, "X"); expect(info, ['1', '2', '3']); store.dispatch(ActionA(abort: false)); expect(store.state, "XX"); expect(info, ['1', '2', '3', '1', '2', '3']); // Won't dispatch, because abortDispatch checks the abort flag. store.dispatch(ActionA(abort: true)); expect(store.state, "XX"); expect(info, ['1', '2', '3', '1', '2', '3']); }); test('Test aborting an action, where the abortDispatch method accesses the state.', () async { // info = []; Store store = Store(initialState: ""); store.dispatch(ActionB()); expect(store.state, "X"); expect(info, ['1', '2', '3']); store.dispatch(ActionB()); expect(store.state, "XX"); expect(info, ['1', '2', '3', '1', '2', '3']); // Won't dispatch, because abortDispatch checks that the state has length 2. store.dispatch(ActionB()); expect(store.state, "XX"); expect(info, ['1', '2', '3', '1', '2', '3']); }); Bdd(feature) .scenario('The action can abort its own dispatch.') .given('An action that returns true (or false) in its abortDispatch method.') .when('The action is dispatched (with dispatch, or dispatchSync, or dispatchAndWait).') .then('It is aborted (or is not aborted, respectively).') .note( 'We have to test dispatch/dispatchSync/dispatchAndWait separately, because they abort in different ways.') .run((_) async { // Dispatch var store = Store(initialState: State(1)); // Doesn't abort, so it increments. store.dispatch(Increment(false)); expect(store.state.count, 2); // Aborts, so it doesn't change. store.dispatch(Increment(true)); expect(store.state.count, 2); // Doesn't abort, so it increments again. store.dispatch(Increment(false)); expect(store.state.count, 3); // DispatchSync store = Store(initialState: State(1)); // Doesn't abort, so it increments. store.dispatchSync(Increment(false)); expect(store.state.count, 2); // Aborts, so it doesn't change. store.dispatchSync(Increment(true)); expect(store.state.count, 2); // Doesn't abort, so it increments again. store.dispatchSync(Increment(false)); expect(store.state.count, 3); // DispatchAndWait store = Store(initialState: State(1)); // Doesn't abort, so it increments. await store.dispatchAndWait(Increment(false)); expect(store.state.count, 2); // Aborts, so it doesn't change. await store.dispatchAndWait(Increment(true)); expect(store.state.count, 2); // Doesn't abort, so it increments again. await store.dispatchAndWait(Increment(false)); expect(store.state.count, 3); }); } class ActionA extends ReduxAction { bool abort; ActionA({required this.abort}); @override bool abortDispatch() => abort; @override void before() { info!.add('1'); } @override String reduce() { info!.add('2'); return state + 'X'; } @override void after() { info!.add('3'); } } class ActionB extends ReduxAction { @override bool abortDispatch() => state.length >= 2; @override void before() { info!.add('1'); } @override String reduce() { info!.add('2'); return state + 'X'; } @override void after() { info!.add('3'); } } class State { final int count; State(this.count); @override String toString() => 'State($count)'; } class Increment extends ReduxAction { final bool ifAbort; Increment(this.ifAbort); @override bool abortDispatch() => ifAbort; @override State reduce() => State(state.count + 1); } ================================================ FILE: test/action_initial_state_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; class State { final int count; State(this.count); @override String toString() => 'State($count)'; } class ChangeAction extends ReduxAction { final int newValue; ChangeAction(this.newValue); @override State reduce() => State(newValue); } class IncrementSync extends ReduxAction { String result = ''; @override void before() { result += 'before initialState: $initialState|'; result += 'before state: $state|'; dispatch(ChangeAction(42)); result += 'before initialState: $initialState|'; result += 'before state: $state|'; } @override State reduce() { result += 'reduce initialState: $initialState|'; result += 'reduce state: $state|'; dispatch(ChangeAction(100)); result += 'reduce initialState: $initialState|'; result += 'reduce state: $state|'; return State(state.count + 1); } @override void after() { result += 'after initialState: $initialState|'; result += 'after state: $state|'; dispatch(ChangeAction(1350)); result += 'after initialState: $initialState|'; result += 'after state: $state|'; } } class IncrementAsync extends ReduxAction { String result = ''; @override Future before() async { result += 'before initialState: $initialState|'; result += 'before state: $state|'; dispatch(ChangeAction(42)); result += 'before initialState: $initialState|'; result += 'before state: $state|'; } @override Future reduce() async { result += 'reduce initialState: $initialState|'; result += 'reduce state: $state|'; dispatch(ChangeAction(100)); result += 'reduce initialState: $initialState|'; result += 'reduce state: $state|'; return State(state.count + 1); } @override void after() { result += 'after initialState: $initialState|'; result += 'after state: $state|'; dispatch(ChangeAction(1350)); result += 'after initialState: $initialState|'; result += 'after state: $state|'; } } void main() { var feature = BddFeature('Action initial state'); Bdd(feature) .scenario('The action has access to its initial state.') .given('SYNC and ASYNC actions.') .when('The "before" and "reduce" and "after" methods are called.') .then('They have access to the store state as it was when the action was dispatched.') .note('The action initial state has nothing to do with the store initial state.') .run((_) async { // SYNC var store = Store(initialState: State(1)); var actionSync = IncrementSync(); store.dispatch(actionSync); expect( actionSync.result, 'before initialState: State(1)|' 'before state: State(1)|' 'before initialState: State(1)|' 'before state: State(42)|' 'reduce initialState: State(1)|' 'reduce state: State(42)|' 'reduce initialState: State(1)|' 'reduce state: State(100)|' 'after initialState: State(1)|' 'after state: State(101)|' 'after initialState: State(1)|' 'after state: State(1350)|'); // ASYNC store = Store(initialState: State(1)); var actionAsync = IncrementAsync(); await store.dispatchAndWait(actionAsync); expect( actionAsync.result, 'before initialState: State(1)|' 'before state: State(1)|' 'before initialState: State(1)|' 'before state: State(42)|' 'reduce initialState: State(1)|' 'reduce state: State(42)|' 'reduce initialState: State(1)|' 'reduce state: State(100)|' 'after initialState: State(1)|' 'after state: State(101)|' 'after initialState: State(1)|' 'after state: State(1350)|'); }); } ================================================ FILE: test/action_status_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late List info; enum When { before, reduce, after } /// IMPORTANT: /// These tests may print errors to the console. This is normal. /// void main() { test('Test detecting that the BEFORE method of an action threw an error.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyAction(whenToThrow: When.before); store.dispatch(actionA); expect(actionA.status.hasFinishedMethodBefore, false); expect(actionA.status.hasFinishedMethodReduce, false); expect(actionA.status.hasFinishedMethodAfter, true); expect(actionA.status.isCompleted, true); expect(actionA.status.isCompletedOk, false); expect(actionA.status.isCompletedFailed, true); expect(actionA.status.originalError, const UserException('During before')); expect(actionA.status.wrappedError, const UserException('During before')); }); test('Test detecting that the REDUCE method of an action threw an error.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyAction(whenToThrow: When.reduce); store.dispatch(actionA); expect(actionA.status.hasFinishedMethodBefore, true); expect(actionA.status.hasFinishedMethodReduce, false); expect(actionA.status.hasFinishedMethodAfter, true); expect(actionA.status.isCompleted, true); expect(actionA.status.isCompletedOk, false); expect(actionA.status.isCompletedFailed, true); expect(actionA.status.originalError, const UserException('During reduce')); expect(actionA.status.wrappedError, const UserException('During reduce')); }); test('Test wrapping the error in the action.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyActionWithWrapError(whenToThrow: When.reduce); try { store.dispatch(actionA); } catch (e) { // This is expected. } expect(actionA.status.hasFinishedMethodBefore, true); expect(actionA.status.hasFinishedMethodReduce, false); expect(actionA.status.hasFinishedMethodAfter, true); expect(actionA.status.isCompleted, true); expect(actionA.status.isCompletedOk, false); expect(actionA.status.isCompletedFailed, true); expect(actionA.status.originalError, const UserException('During reduce')); expect(actionA.status.wrappedError, 'wrapped error in action: UserException{During reduce}'); }); test('Test wrapping the error globally with the globalWrapError (Store constructor).', () async { // info = []; Store store = Store( initialState: "", globalWrapError: MyGlobalWrapError(), ); var actionA = MyAction(whenToThrow: When.reduce); try { store.dispatch(actionA); } catch (e) { // This is expected. } expect(actionA.status.hasFinishedMethodBefore, true); expect(actionA.status.hasFinishedMethodReduce, false); expect(actionA.status.hasFinishedMethodAfter, true); expect(actionA.status.isCompleted, true); expect(actionA.status.isCompletedOk, false); expect(actionA.status.isCompletedFailed, true); expect(actionA.status.originalError, const UserException('During reduce')); expect(actionA.status.wrappedError, 'global wrapped error: UserException{During reduce}'); }); test( "Test detecting that the AFTER method of an action threw an error. " "An AFTER method shouldn't throw. But if it does, the error will be " "thrown asynchronously (after the async gap).", () async { // info = []; Store store = Store(initialState: ""); var hasThrown = false; runZonedGuarded(() { var actionA = MyAction(whenToThrow: When.after); store.dispatch(actionA); expect(actionA.status.hasFinishedMethodBefore, true); expect(actionA.status.hasFinishedMethodReduce, true); expect(actionA.status.hasFinishedMethodAfter, true); expect(actionA.status.isCompleted, true); expect(actionA.status.isCompletedOk, true); expect(actionA.status.isCompletedFailed, false); expect(actionA.status.originalError, isNull); expect(actionA.status.wrappedError, isNull); }, (error, stackTrace) { hasThrown = true; expect( error, "Method 'MyAction.after()' has thrown an error:\n" " 'UserException{During after}'.:\n" " UserException{During after}"); }); await Future.delayed(const Duration(milliseconds: 10)); expect(hasThrown, isTrue); }); test('Test detecting that the action threw no errors.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyAction(whenToThrow: null); store.dispatch(actionA); expect(actionA.status.hasFinishedMethodBefore, true); expect(actionA.status.hasFinishedMethodReduce, true); expect(actionA.status.hasFinishedMethodAfter, true); expect(actionA.status.isCompleted, true); expect(actionA.status.isCompletedOk, true); expect(actionA.status.isCompletedFailed, false); expect(actionA.status.originalError, isNull); expect(actionA.status.wrappedError, isNull); }); test('The status.context contains the action and store after a successful dispatch.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyAction(whenToThrow: null); store.dispatch(actionA); expect(actionA.status.context, isNotNull); var (action, ctxStore) = actionA.status.context!; expect(action, same(actionA)); expect(ctxStore, same(store)); }); test('The status.context contains the action and store when the action threw an error.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyAction(whenToThrow: When.before); store.dispatch(actionA); expect(actionA.status.isCompletedFailed, true); expect(actionA.status.context, isNotNull); var (action1, store1) = actionA.status.context!; expect(action1, same(actionA)); expect(store1, same(store)); var actionB = MyAction(whenToThrow: When.reduce); store.dispatch(actionB); expect(actionB.status.isCompletedFailed, true); expect(actionB.status.context, isNotNull); var (action2, store2) = actionB.status.context!; expect(action2, same(actionB)); expect(store2, same(store)); }); test('The status.context contains the action and store when dispatch is aborted.', () async { // info = []; Store store = Store(initialState: ""); var actionA = MyAbortAction(); ActionStatus returnedStatus = await store.dispatchAndWait(actionA); expect(returnedStatus.isDispatchAborted, true); expect(returnedStatus.context, isNotNull); var (action, ctxStore) = returnedStatus.context!; expect(action, same(actionA)); expect(ctxStore, same(store)); }); test('The status.context is null before the action is dispatched.', () async { // var actionA = MyAction(whenToThrow: null); expect(actionA.status.context, isNull); }); } class MyAction extends ReduxAction { When? whenToThrow; MyAction({this.whenToThrow}); @override void before() { info.add('1'); if (whenToThrow == When.before) throw const UserException("During before"); } @override String reduce() { info.add('2'); if (whenToThrow == When.reduce) throw const UserException("During reduce"); return state + 'X'; } @override void after() { if (whenToThrow == When.after) throw const UserException("During after"); info.add('3'); } } class MyActionWithWrapError extends ReduxAction { When? whenToThrow; MyActionWithWrapError({this.whenToThrow}); @override void before() { info.add('1'); if (whenToThrow == When.before) throw const UserException("During before"); } @override String reduce() { info.add('2'); if (whenToThrow == When.reduce) throw const UserException("During reduce"); return state + 'X'; } @override void after() { if (whenToThrow == When.after) throw const UserException("During after"); info.add('3'); } @override Object? wrapError(Object error, StackTrace stackTrace) => 'wrapped error in action: $error'; } class MyAbortAction extends ReduxAction { @override bool abortDispatch() => true; @override String reduce() => state; } class MyGlobalWrapError implements GlobalWrapError { @override Object? wrap(error, stackTrace, action) => 'global wrapped error: $error'; } ================================================ FILE: test/action_to_string_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux void main() { test('NavigateAction toString() and type.', () async { // var route1 = MaterialPageRoute(builder: (BuildContext ctx) => Container()); var route2 = CupertinoPageRoute(builder: (BuildContext ctx) => Container()); // --- var action = NavigateAction.push(route1); expect(action.toString(), 'Action NavigateAction.push(MaterialPageRoute(RouteSettings(none, null), animation: null))'); expect(action.type, NavigateType.push); // --- action = NavigateAction.pop(); expect(action.toString(), 'Action NavigateAction.pop()'); expect(action.type, NavigateType.pop); action = NavigateAction.pop(true); expect(action.toString(), 'Action NavigateAction.pop(true)'); expect(action.type, NavigateType.pop); // --- action = NavigateAction.popAndPushNamed("routeName"); expect(action.toString(), 'Action NavigateAction.popAndPushNamed(routeName)'); expect(action.type, NavigateType.popAndPushNamed); action = NavigateAction.popAndPushNamed("routeName", result: true); expect(action.toString(), 'Action NavigateAction.popAndPushNamed(routeName, result: true)'); expect(action.type, NavigateType.popAndPushNamed); // --- action = NavigateAction.pushNamed("routeName"); expect(action.toString(), 'Action NavigateAction.pushNamed(routeName)'); expect(action.type, NavigateType.pushNamed); // --- action = NavigateAction.pushReplacement(route1); expect( action.toString(), 'Action NavigateAction.pushReplacement(MaterialPageRoute(' 'RouteSettings(none, null), animation: null)' ')'); expect(action.type, NavigateType.pushReplacement); action = NavigateAction.pushReplacement(route1, result: true); expect( action.toString(), 'Action NavigateAction.pushReplacement(MaterialPageRoute(' 'RouteSettings(none, null), animation: null), result: true' ')'); expect(action.type, NavigateType.pushReplacement); // --- action = NavigateAction.pushAndRemoveUntil(route1, (_) => true); expect( action.toString(), 'Action NavigateAction.pushAndRemoveUntil(' 'MaterialPageRoute(RouteSettings(none, null), animation: null), predicate' ')'); expect(action.type, NavigateType.pushAndRemoveUntil); // --- action = NavigateAction.replace(oldRoute: route1, newRoute: route2); expect( action.toString(), 'Action NavigateAction.replace(' 'oldRoute: MaterialPageRoute(RouteSettings(none, null), animation: null), newRoute: CupertinoPageRoute(RouteSettings(none, null), animation: null)' ')'); expect(action.type, NavigateType.replace); action = NavigateAction.replace(oldRoute: null, newRoute: null); expect(action.toString(), 'Action NavigateAction.replace(oldRoute: null, newRoute: null)'); expect(action.type, NavigateType.replace); // --- action = NavigateAction.replaceRouteBelow(anchorRoute: route1, newRoute: route2); expect( action.toString(), 'Action NavigateAction.replaceRouteBelow(' 'anchorRoute: MaterialPageRoute(RouteSettings(none, null), animation: null), newRoute: CupertinoPageRoute(RouteSettings(none, null), animation: null)' ')'); expect(action.type, NavigateType.replaceRouteBelow); action = NavigateAction.replaceRouteBelow(anchorRoute: null, newRoute: null); expect(action.toString(), 'Action NavigateAction.replaceRouteBelow(anchorRoute: null, newRoute: null)'); expect(action.type, NavigateType.replaceRouteBelow); // --- action = NavigateAction.pushReplacementNamed("routeName"); expect(action.toString(), 'Action NavigateAction.pushReplacementNamed(routeName)'); expect(action.type, NavigateType.pushReplacementNamed); // --- action = NavigateAction.pushNamedAndRemoveUntil("routeName", (_) => true); expect( action.toString(), 'Action NavigateAction.pushNamedAndRemoveUntil(routeName, predicate)'); expect(action.type, NavigateType.pushNamedAndRemoveUntil); // --- action = NavigateAction.pushNamedAndRemoveAll("routeName"); expect(action.toString(), 'Action NavigateAction.pushNamedAndRemoveAll(routeName)'); expect(action.type, NavigateType.pushNamedAndRemoveAll); // --- action = NavigateAction.popUntil((_) => true); expect(action.toString(), 'Action NavigateAction.popUntil(predicate)'); expect(action.type, NavigateType.popUntil); // --- action = NavigateAction.removeRoute(route1); expect( action.toString(), 'Action NavigateAction.removeRoute(' 'MaterialPageRoute(RouteSettings(none, null), animation: null)' ')'); expect(action.type, NavigateType.removeRoute); // --- action = NavigateAction.removeRouteBelow(route1); expect( action.toString(), 'Action NavigateAction.removeRouteBelow(' 'MaterialPageRoute(RouteSettings(none, null), animation: null)' ')'); expect(action.type, NavigateType.removeRouteBelow); // --- action = NavigateAction.popUntilRouteName("routeName"); expect(action.toString(), 'Action NavigateAction.popUntilRouteName(routeName)'); expect(action.type, NavigateType.popUntilRouteName); // --- action = NavigateAction.popUntilRoute(route1); expect( action.toString(), 'Action NavigateAction.popUntilRoute(' 'MaterialPageRoute(RouteSettings(none, null), animation: null)' ')'); expect(action.type, NavigateType.popUntilRoute); }); } ================================================ FILE: test/action_wrap_reduce2_test.dart ================================================ import 'dart:async' show FutureOr; import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('Knowing if wrapReduce is overridden, sync, or async', () async { // var xN = ActionNullableX(); var yN = ActionNullableY(); var x = ActionX(); var y = ActionY(); var z = ActionZ(); print(x.wrapReduce.runtimeType); print(xN.wrapReduce.runtimeType); print(y.wrapReduce.runtimeType); print(yN.wrapReduce.runtimeType); print(z.wrapReduce.runtimeType); print('\nActionX -> true / false / true'); print('ifWrapReduceOverridden ${x.ifWrapReduceOverridden()}'); print('ifWrapReduceSync ${x.ifWrapReduceOverridden_Sync()}'); print('ifWrapReduceAsync ${x.ifWrapReduceOverridden_Async()}'); expect(x.ifWrapReduceOverridden(), true); expect(x.ifWrapReduceOverridden_Sync(), false); expect(x.ifWrapReduceOverridden_Async(), true); print('\nActionNullableX -> true / false / true'); print('ifWrapReduceOverridden ${xN.ifWrapReduceOverridden()}'); print('ifWrapReduceSync ${xN.ifWrapReduceOverridden_Sync()}'); print('ifWrapReduceAsync ${xN.ifWrapReduceOverridden_Async()}'); expect(xN.ifWrapReduceOverridden(), true); expect(xN.ifWrapReduceOverridden_Sync(), false); expect(xN.ifWrapReduceOverridden_Async(), true); print('\nActionY -> true / true / false'); print('ifWrapReduceOverridden ${y.ifWrapReduceOverridden()}'); print('ifWrapReduceSync ${y.ifWrapReduceOverridden_Sync()}'); print('ifWrapReduceAsync ${y.ifWrapReduceOverridden_Async()}'); expect(y.ifWrapReduceOverridden(), true); expect(y.ifWrapReduceOverridden_Sync(), true); expect(y.ifWrapReduceOverridden_Async(), false); print('\nActionNullableY -> true / true / false'); print('ifWrapReduceOverridden ${yN.ifWrapReduceOverridden()}'); print('ifWrapReduceSync ${yN.ifWrapReduceOverridden_Sync()}'); print('ifWrapReduceAsync ${yN.ifWrapReduceOverridden_Async()}'); expect(yN.ifWrapReduceOverridden(), true); expect(yN.ifWrapReduceOverridden_Sync(), true); expect(yN.ifWrapReduceOverridden_Async(), false); print('\nActionZ => false false false'); print('ifWrapReduceOverridden ${z.ifWrapReduceOverridden()}'); print('ifWrapReduceSync ${z.ifWrapReduceOverridden_Sync()}'); print('ifWrapReduceAsync ${z.ifWrapReduceOverridden_Async()}'); expect(z.ifWrapReduceOverridden(), false); expect(z.ifWrapReduceOverridden_Sync(), false); expect(z.ifWrapReduceOverridden_Async(), false); }); } abstract class BaseAction { // static const _wrapReduceFlag = Object(); FutureOr reduce(); FutureOr wrapReduce(Reducer reduce) { return null; } bool ifWrapReduceOverridden_Sync() => wrapReduce is St? Function(Reducer); bool ifWrapReduceOverridden_Async() => wrapReduce is Future Function(Reducer); bool ifWrapReduceOverridden() => ifWrapReduceOverridden_Async() || ifWrapReduceOverridden_Sync(); } /// SYNC: This action overrides the [wrapReduce] method. class ActionNullableX extends BaseAction { @override int? reduce() => 123; @override Future wrapReduce(Reducer reduce) async { print('overriden'); return null; } } /// SYNC: This action overrides the [wrapReduce] method. class ActionX extends BaseAction { @override int? reduce() => 123; @override Future wrapReduce(Reducer reduce) async { print('overriden'); return 0; } } /// This action does NOT override the [wrapReduce] method. class ActionNullableY extends BaseAction { @override int reduce() => 456; @override int? wrapReduce(Reducer reduce) { print('overriden'); return null; } } /// This action does NOT override the [wrapReduce] method. class ActionY extends BaseAction { @override int reduce() => 456; @override int wrapReduce(Reducer reduce) { print('overriden'); return 0; } } /// This action does NOT override the [wrapReduce] method. class ActionZ extends BaseAction { @override FutureOr reduce() => 456; } ================================================ FILE: test/action_wrap_reduce_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('action.isSync', () async { expect(IncrementReduceSyncNoBeforeNoWrap().isSync(), isTrue); expect(IncrementReduceAsyncNoBeforeNoWrap().isSync(), isFalse); expect(IncrementReduceSyncBeforeSyncNoWrap().isSync(), isTrue); expect(IncrementReduceSyncBeforeAsyncNoWrap().isSync(), isFalse); expect(IncrementReduceSyncNoBeforeWrapSync().isSync(), isTrue); expect(IncrementReduceSyncNoBeforeWrapSync2().isSync(), isTrue); expect(IncrementReduceSyncNoBeforeWrapSync3().isSync(), isTrue); expect(IncrementReduceSyncNoBeforeWrapAsync().isSync(), isFalse); expect(IncrementReduceSyncNoBeforeWrapAsync2().isSync(), isFalse); expect(IncrementReduceSyncNoBeforeWrapAsync3().isSync(), isFalse); }); } class State { final int count; State(this.count); } class IncrementReduceSyncNoBeforeNoWrap extends ReduxAction { @override State reduce() => State(state.count + 1); } class IncrementReduceAsyncNoBeforeNoWrap extends ReduxAction { @override Future reduce() async { return State(state.count + 1); } } class IncrementReduceSyncBeforeSyncNoWrap extends ReduxAction { @override void before() {} @override State reduce() => State(state.count + 1); } class IncrementReduceSyncBeforeAsyncNoWrap extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 1)); } @override State reduce() => State(state.count + 1); } class IncrementReduceSyncNoBeforeWrapSync extends ReduxAction { @override State reduce() => State(state.count + 1); @override State? wrapReduce(Reducer reduce) { return reduce() as State?; } } class IncrementReduceSyncNoBeforeWrapSync2 extends ReduxAction { @override State reduce() => State(state.count + 1); @override State wrapReduce(Reducer reduce) { return reduce() as State; } } class IncrementReduceSyncNoBeforeWrapSync3 extends ReduxAction { @override State reduce() => State(state.count + 1); @override State wrapReduce(Reducer reduce) { return reduce() as State; } } class IncrementReduceSyncNoBeforeWrapAsync extends ReduxAction { @override State reduce() => State(state.count + 1); @override Future wrapReduce(Reducer reduce) async { await microtask; return reduce(); } } class IncrementReduceSyncNoBeforeWrapAsync2 extends ReduxAction { @override State reduce() => State(state.count + 1); @override Future wrapReduce(Reducer reduce) async { return reduce() as Future; } } class IncrementReduceSyncNoBeforeWrapAsync3 extends ReduxAction { @override State reduce() => State(state.count + 1); @override Future wrapReduce(Reducer reduce) async { return reduce() as Future; } } ================================================ FILE: test/after_throws_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late List info; /// IMPORTANT: /// These tests may print errors to the console. This is normal. /// void main() { test('If the after method throws, the error will be thrown asynchronously.', () async { // dynamic error; dynamic asyncError; late Store store; await runZonedGuarded(() async { info = []; store = Store(initialState: ""); try { store.dispatch(ActionA()); } catch (_error) { error = _error; } await Future.delayed(const Duration(seconds: 1)); }, (_asyncError, s) { asyncError = _asyncError; }); expect(store.state, "A"); expect(info, [ 'A.before state=""', 'A.reduce state=""', 'A.after state="A"', ]); expect(error, isNull); expect( asyncError, "Method 'ActionA.after()' has thrown an error:\n" " 'some-error'.:\n" " some-error"); }); } class ActionA extends ReduxAction { @override void before() { info.add('A.before state="$state"'); } @override String reduce() { info.add('A.reduce state="$state"'); return state + 'A'; } @override void after() { info.add('A.after state="$state"'); throw "some-error"; } } ================================================ FILE: test/before_reduce_after_order_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux late List info; void main() { // test('Method call sequence for sync reducer.', () async { // info = []; Store store = Store(initialState: ""); store.dispatch(ActionA()); expect(store.state, "A"); expect(info, [ 'A.before state=""', 'A.reduce state=""', 'A.after state="A"', ]); }); test( 'Method call sequence for async reducer. ' 'The reducer is async because the method returns Future.', () async { // info = []; Store store = Store(initialState: ""); await store.dispatch(ActionB()); expect(store.state, "B"); expect(info, [ 'B.before state=""', 'B.reduce1 state=""', 'B.reduce2 state=""', 'B.after state="B"', ]); }); test( 'Method call sequence for async reducer. ' 'The reducer is async because the REDUCE method returns Future.', () async { // info = []; Store store = Store(initialState: ""); // B is dispatched first, but will finish last, because it's async. var f1 = store.dispatchAndWait(ActionB()); var f2 = store.dispatchAndWait(ActionA()); await Future.wait([f1, f2]); expect(store.state, "AB"); expect(info, [ 'B.before state=""', 'B.reduce1 state=""', 'A.before state=""', 'A.reduce state=""', 'A.after state="A"', 'B.reduce2 state="A"', 'B.after state="AB"' ]); }); test( 'Method call sequence for async reducer. ' 'The reducer is async because the BEFORE method returns Future.', () async { // info = []; Store store = Store(initialState: ""); // C is dispatched first, but will finish last, because it's async. var f1 = store.dispatchAndWait(ActionC()); var f2 = store.dispatchAndWait(ActionA()); await Future.wait([f1, f2]); expect(store.state, "AC"); expect(info, [ 'C.before state=""', 'A.before state=""', 'A.reduce state=""', 'A.after state="A"', 'C.reduce state="A"', 'C.after state="AC"' ]); }); test( 'Method call sequence for async reducer. ' 'The reducer is async because the BEFORE method returns Future.' 'Shows what happens if the before method actually awaits.', () async { // info = []; Store store = Store(initialState: ""); // D is dispatched first, but will finish last, because it's async. var f1 = store.dispatchAndWait(ActionD()); var f2 = store.dispatchAndWait(ActionA()); await Future.wait([f1, f2]); expect(store.state, "AD"); expect(info, [ 'D.before1 state=""', 'A.before state=""', 'A.reduce state=""', 'A.after state="A"', 'D.before2 state="A"', 'D.reduce state="A"', 'D.after state="AD"' ]); }); test( 'What happens when the after method of a sync reducer dispatches another action? ' 'The state is changed by the reduce method before the after method is executed.', () async { // info = []; Store store = Store(initialState: ""); await store.dispatch(ActionE()); // expect(store.state, "EA"); expect(info, [ 'E.before state=""', 'E.reduce state=""', 'E.after1 state="E"', 'A.before state="E"', 'A.reduce state="E"', 'A.after state="EA"', 'E.after2 state="EA"' ]); }); test( 'What happens when the after method of a async reducer dispatches another action? ' 'The state is changed by the reduce method before the after method is executed.', () async { // info = []; Store store = Store(initialState: ""); await store.dispatch(ActionF()); // expect(store.state, "FA"); expect(info, [ 'F.before state=""', 'F.reduce1 state=""', 'F.reduce2 state=""', 'F.after1 state="F"', 'A.before state="F"', 'A.reduce state="F"', 'A.after state="FA"', 'F.after2 state="FA"' ]); }); } class ActionA extends ReduxAction { @override void before() { info.add('A.before state="$state"'); } @override String reduce() { info.add('A.reduce state="$state"'); return state + 'A'; } @override void after() { info.add('A.after state="$state"'); } } class ActionB extends ReduxAction { @override void before() { info.add('B.before state="$state"'); } @override Future reduce() async { info.add('B.reduce1 state="$state"'); await Future.delayed(const Duration(milliseconds: 50)); info.add('B.reduce2 state="$state"'); return state + 'B'; } @override void after() { info.add('B.after state="$state"'); } } class ActionC extends ReduxAction { @override Future before() async { info.add('C.before state="$state"'); } @override String reduce() { info.add('C.reduce state="$state"'); return state + 'C'; } @override void after() { info.add('C.after state="$state"'); } } class ActionD extends ReduxAction { @override Future before() async { info.add('D.before1 state="$state"'); await Future.delayed(const Duration(milliseconds: 10)); info.add('D.before2 state="$state"'); } @override String reduce() { info.add('D.reduce state="$state"'); return state + 'D'; } @override void after() { info.add('D.after state="$state"'); } } class ActionE extends ReduxAction { @override void before() { info.add('E.before state="$state"'); } @override String reduce() { info.add('E.reduce state="$state"'); return state + 'E'; } @override void after() { info.add('E.after1 state="$state"'); store.dispatch(ActionA()); info.add('E.after2 state="$state"'); } } class ActionF extends ReduxAction { @override void before() { info.add('F.before state="$state"'); } @override Future reduce() async { info.add('F.reduce1 state="$state"'); await Future.delayed(const Duration(milliseconds: 10)); info.add('F.reduce2 state="$state"'); return state + 'F'; } @override void after() { info.add('F.after1 state="$state"'); store.dispatch(ActionA()); info.add('F.after2 state="$state"'); } } ================================================ FILE: test/before_throwing_errors_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// This is meant to solve this issue: /// - BEFORE() SWALLOWS REDUCER() ERRORS /// https://github.com/marcglasberg/async_redux/issues/105 /// void main() { test('1).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionBeforeFutureOr()); } catch (_error) { error = _error; } expect(store.state, ""); expect( error, StoreException("Before should return `void` or `Future`. " "Do not return `FutureOr`.")); }); test('1).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionSyncBeforeThrowsError()); } catch (_error) { error = _error; } expect(store.state, ""); expect(error, StoreException("ERROR 1")); }); test('2).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionAsyncBeforeThrowsError()); } catch (_error) { error = _error; } expect(store.state, ""); expect(error, StoreException("ERROR 2")); }); test('3).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionAsyncBeforeThrowsErrorAsync()); } catch (_error) { error = _error; } expect(store.state, ""); expect(error, StoreException("ERROR B")); }); test('4).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionSyncBeforeThrowsErrorWithWrapError()); } catch (_error) { error = _error; } expect(store.state, ""); expect(error, WrappedError(StoreException("ERROR 4"))); }); test('5).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionAsyncBeforeThrowsErrorWithWrapError()); } catch (_error) { error = _error; } expect(store.state, ""); expect(error, WrappedError(StoreException("ERROR 5"))); }); test('6).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionAsyncBeforeThrowsErrorAsyncWithWrapError()); } catch (_error) { error = _error; } expect(store.state, ""); expect(error, WrappedError(StoreException("ERROR B"))); }); test('7).', () async { // Store store = Store(initialState: ""); Object? error; try { await store.dispatch(ActionWithBeforeAndReducerThatThrowsErrorWithWrapError()); } catch (_error) { error = _error; } expect(store.state, "C"); expect(error, WrappedError(StoreException("ERROR 7"))); }); } class ActionBeforeFutureOr extends ReduxAction { @override FutureOr before() async { await Future.delayed(const Duration(milliseconds: 10)); } @override String reduce() { return state + '0'; } } class ActionSyncBeforeThrowsError extends ReduxAction { @override void before() { throw StoreException("ERROR 1"); } @override String reduce() { return state + '1'; } } class ActionAsyncBeforeThrowsError extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 50)); throw StoreException("ERROR 2"); } @override String reduce() { return state + '2'; } } class ActionAsyncBeforeThrowsErrorAsync extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 50)); dispatch(ActionB()); } @override String reduce() { return state + '3'; } } class ActionB extends ReduxAction { @override String reduce() { throw StoreException("ERROR B"); } } class ActionC extends ReduxAction { @override String reduce() { return state + 'C'; } } class ActionSyncBeforeThrowsErrorWithWrapError extends ReduxAction { @override void before() { throw StoreException("ERROR 4"); } @override String reduce() { return state + '4'; } @override Object? wrapError(Object error, StackTrace stackTrace) { return WrappedError(error); } } class ActionAsyncBeforeThrowsErrorWithWrapError extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 50)); throw StoreException("ERROR 5"); } @override String reduce() { return state + '5'; } @override Object? wrapError(Object error, StackTrace stackTrace) { return WrappedError(error); } } class ActionAsyncBeforeThrowsErrorAsyncWithWrapError extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 50)); dispatch(ActionB()); } @override String reduce() { return state + '6'; } @override Object? wrapError(Object error, StackTrace stackTrace) => WrappedError(error); } class ActionWithBeforeAndReducerThatThrowsErrorWithWrapError extends ReduxAction { @override void before() => dispatch(ActionC()); @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 100)); throw StoreException("ERROR 7"); } @override Object? wrapError(Object error, StackTrace stackTrace) => WrappedError(error); } class WrappedError { final Object? error; WrappedError(this.error); @override String toString() { return 'WrappedError{error: $error | ${error.runtimeType}}'; } @override bool operator ==(Object other) => identical(this, other) || other is WrappedError && runtimeType == other.runtimeType && error == other.error; @override int get hashCode => error.hashCode; } ================================================ FILE: test/cache_test.dart ================================================ import 'package:async_redux/src/cache.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var stateNames = List.unmodifiable(["Juan", "Anna", "Bill", "Zack", "Arnold", "Amanda"]); test('Test 1 state with 0 parameters.', () { // var selector = cache1state((int limit) => () => stateNames.take(limit).toList()); var memoA1 = selector(1)(); var memoA2 = selector(1)(); expect(memoA1, ["Juan"]); expect(identical(memoA1, memoA2), isTrue); var memoB1 = selector(2)(); var memoB2 = selector(2)(); expect(memoB1, ["Juan", "Anna"]); expect(identical(memoB1, memoB2), isTrue); }); test('Test results are forgotten when the state changes (1 state with 0 parameters).', () { // var selector = cache1state((int limit) => () => stateNames.take(limit).toList()); var memoA1 = selector(1)(); var memoA2 = selector(1)(); expect(memoA1, ["Juan"]); expect(identical(memoA1, memoA2), isTrue); // Another state with another parameter. selector(2)(); // Try reading the previous state, with the same parameter as before. var memoA5 = selector(1)(); expect(memoA5, ["Juan"]); expect(identical(memoA5, memoA1), isFalse); }); test('Test 1 state with 1 parameter.', () { // var selector = cache1state_1param((List state) => (String startString) => state.where((str) => str.startsWith(startString)).toList()); var memoA1 = selector(stateNames)("A"); var memoA2 = selector(stateNames)("A"); expect(memoA1, ["Anna", "Arnold", "Amanda"]); expect(identical(memoA1, memoA2), isTrue); selector(stateNames)("B"); var memoA3 = selector(stateNames)("A"); expect(memoA3, ["Anna", "Arnold", "Amanda"]); expect(identical(memoA1, memoA3), isTrue); }); test('Test results are forgotten when the state changes (1 state with 1 parameter).', () { // var selector = cache1state_1param((List state) => (String startString) { return state.where((str) => str.startsWith(startString)).toList(); }); var memoA1 = selector(stateNames)("A"); var memoA2 = selector(stateNames)("A"); expect(memoA1, ["Anna", "Arnold", "Amanda"]); expect(identical(memoA1, memoA2), isTrue); // Another state with another parameter. selector(List.of(stateNames))("B"); selector(stateNames)("B"); // Try reading the previous state, with the same parameter as before. var memoA5 = selector(stateNames)("A"); expect(memoA5, ["Anna", "Arnold", "Amanda"]); expect(identical(memoA5, memoA1), isFalse); }); test('Test 1 state with 2 parameters.', () { // var selector = cache1state_2params((List state) => (String startString, String endString) { return state .where((str) => str.startsWith(startString) && str.endsWith(endString)) .toList(); }); // Concatenate. String otherA = "a" + ""; // ignore: prefer_adjacent_string_concatenation expect(identical("a", otherA), isFalse); var memoA1 = selector(stateNames)("A", "a"); var memoA2 = selector(stateNames)("A", otherA); expect(memoA1, ["Anna", "Amanda"]); expect(identical(memoA1, memoA2), isTrue); var memoB1 = selector(stateNames)("A", "d"); var memoB2 = selector(stateNames)("A", "d"); expect(memoB1, ["Arnold"]); expect(identical(memoB1, memoB2), isTrue); var memoA3 = selector(stateNames)("A", "a"); expect(memoA1, ["Anna", "Amanda"]); expect(identical(memoA1, memoA3), isTrue); }); test('Test results are forgotten when the state changes (1 state with 2 parameters).', () { // var selector = cache1state_2params((List state) => (String startString, String endString) { return state .where((str) => str.startsWith(startString) && str.endsWith(endString)) .toList(); }); var memoA1 = selector(stateNames)("A", "a"); var memoA2 = selector(stateNames)("A", "a"); expect(memoA1, ["Anna", "Amanda"]); expect(identical(memoA1, memoA2), isTrue); // Another state with another parameter. selector(List.of(stateNames))("B", "l"); selector(stateNames)("B", "l"); // Try reading the previous state, with the same parameter as before. var memoA5 = selector(stateNames)("A", "a"); expect(memoA5, ["Anna", "Amanda"]); expect(identical(memoA5, memoA1), isFalse); }); test('Test 2 states with 0 parameters.', () { // var selector = cache2states((List names, int limit) => () => names.where((str) => str.startsWith("A")).take(limit).toList()); var memoA1 = selector(stateNames, 1)(); var memoA2 = selector(stateNames, 1)(); expect(memoA1, ["Anna"]); expect(memoA2, ["Anna"]); expect(identical(memoA1, memoA2), isTrue); var memoB1 = selector(stateNames, 2)(); var memoB2 = selector(stateNames, 2)(); expect(memoB1, ["Anna", "Arnold"]); expect(identical(memoB1, memoB2), isTrue); }); test('Test results are forgotten when the state changes (2 states with 0 parameters).', () { // var selector = cache2states((List names, int limit) => () { return names.where((str) => str.startsWith("A")).take(limit).toList(); }); var memoA1 = selector(stateNames, 1)(); var memoA2 = selector(stateNames, 1)(); expect(memoA1, ["Anna"]); expect(identical(memoA1, memoA2), isTrue); // Another state with another parameter. selector(stateNames, 2)(); // Try reading the previous state, with the same parameter as before. var memoA5 = selector(stateNames, 1)(); expect(memoA5, ["Anna"]); expect(identical(memoA5, memoA1), isFalse); }); test('Test 2 states with 1 parameter.', () { // var selector = cache2states_1param((List names, int limit) => (String searchString) { return names.where((str) => str.startsWith(searchString)).take(limit).toList(); }); var memoA1 = selector(stateNames, 1)("A"); var memoA2 = selector(stateNames, 1)("A"); expect(memoA1, ["Anna"]); expect(identical(memoA1, memoA2), isTrue); var memoB1 = selector(stateNames, 2)("A"); var memoB2 = selector(stateNames, 2)("A"); expect(memoB1, ["Anna", "Arnold"]); expect(identical(memoB1, memoB2), isTrue); var memoC = selector(stateNames, 2)("B"); expect(memoC, ["Bill"]); var memoD = selector(stateNames, 2)("A"); expect(identical(memoD, memoB1), isTrue); // Has to forget, because the state changed. selector(stateNames, 1)("A"); expect(identical(memoA1, memoC), isFalse); }); test('Test results are forgotten when the state changes (2 states with 1 parameter).', () { // var selector = cache2states_1param((List names, int limit) => (String searchString) { return names.where((str) => str.startsWith(searchString)).take(limit).toList(); }); var memoA1 = selector(stateNames, 1)("A"); var memoA2 = selector(stateNames, 1)("A"); expect(memoA1, ["Anna"]); expect(identical(memoA1, memoA2), isTrue); // Another state with another parameter. selector(stateNames, 2)("B"); selector(stateNames, 1)("B"); // Try reading the previous state, with the same parameter as before. var memoA5 = selector(stateNames, 1)("A"); expect(memoA5, ["Anna"]); expect(identical(memoA5, memoA1), isFalse); }); test('Test 2 states with 2 parameters.', () { // var selector = cache2states_2params( (List names, int limit) => (String startString, String endString) { return names .where((str) => str.startsWith(startString) && str.endsWith(endString)) .take(limit) .toList(); }); var memoA1 = selector(stateNames, 1)("A", "a"); var memoA2 = selector(stateNames, 1)("A", "a"); expect(memoA1, ["Anna"]); expect(identical(memoA1, memoA2), isTrue); var memoB1 = selector(stateNames, 2)("A", "a"); var memoB2 = selector(stateNames, 2)("A", "a"); expect(memoB1, ["Anna", "Amanda"]); expect(identical(memoB1, memoB2), isTrue); }); test('Test results are forgotten when the state changes (2 states with 2 parameters).', () { // var selector = cache2states_2params( (List names, int limit) => (String startString, String endString) { return names .where((str) => str.startsWith(startString) && str.endsWith(endString)) .take(limit) .toList(); }); var memoA1 = selector(stateNames, 1)("A", "a"); var memoA2 = selector(stateNames, 1)("A", "a"); expect(memoA1, ["Anna"]); expect(identical(memoA1, memoA2), isTrue); // Another state with another parameter. selector(stateNames, 2)("B", "l"); selector(stateNames, 1)("B", "l"); // Try reading the previous state, with the same parameter as before. var memoA5 = selector(stateNames, 1)("A", "a"); expect(memoA5, ["Anna"]); expect(identical(memoA5, memoA1), isFalse); }); test('Changing the second or the first state, it should forget the cached value.', () { // var stateNames1 = List.unmodifiable(["A1a", "A2a", "A3x", "B4a", "B5a", "B6x"]); var selector = cache2states_2params( (List names, int limit) => (String startString, String endString) { return names .where((str) => str.startsWith(startString) && str.endsWith(endString)) .take(limit) .toList(); }); var memo1 = selector(stateNames1, 1)("A", "a"); expect(memo1, ["A1a"]); var memo2 = selector(stateNames1, 2)("A", "a"); expect(memo2, ["A1a", "A2a"]); var memo3 = selector(stateNames1, 1)("A", "a"); expect(memo3, ["A1a"]); var memo4 = selector(stateNames1, 2)("A", "a"); expect(memo4, ["A1a", "A2a"]); expect(identical(memo1, memo3), isFalse); expect(identical(memo2, memo4), isFalse); // --- var stateNames2 = List.unmodifiable(["A1a", "A2a", "A3x", "B4a", "B5a", "B6x"]); var memo5 = selector(stateNames1, 1)("A", "a"); expect(memo5, ["A1a"]); var memo6 = selector(stateNames2, 1)("A", "a"); expect(memo6, ["A1a"]); var memo7 = selector(stateNames1, 1)("A", "a"); expect(memo7, ["A1a"]); var memo8 = selector(stateNames2, 1)("A", "a"); expect(memo8, ["A1a"]); expect(identical(memo5, memo7), isFalse); expect(identical(memo6, memo8), isFalse); }); } ================================================ FILE: test/check_internet_mixin_test.dart ================================================ import 'package:bdd_framework/bdd_framework.dart'; void main() { var feature = BddFeature('Check internet actions'); // TODO: Test mixins: CheckInternet, NoDialog, AbortWhenNoInternet } ================================================ FILE: test/context_environment_test.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('AsyncRedux context.env functionality', () { testWidgets('Environment is accessible via context.env', (WidgetTester tester) async { final initialState = AppState(counter: 0); final environment = TestEnvironment( apiUrl: 'https://api.example.com', apiKey: 'test-key-123', ); final store = Store( initialState: initialState, environment: environment, ); TestEnvironment? capturedEnv; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { capturedEnv = context.env; return Text('API: ${capturedEnv!.apiUrl}'); }), ), ), ), ); expect(capturedEnv, isNotNull); expect(capturedEnv!.apiUrl, 'https://api.example.com'); expect(capturedEnv!.apiKey, 'test-key-123'); expect(find.text('API: https://api.example.com'), findsOneWidget); }); testWidgets('Accessing environment does not trigger rebuilds', (WidgetTester tester) async { final initialState = AppState(counter: 0); final environment = TestEnvironment( apiUrl: 'https://api.example.com', apiKey: 'test-key-123', ); final store = Store( initialState: initialState, environment: environment, ); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; // Access environment - should not cause rebuilds var env = context.env; return Text('API: ${env.apiUrl}'); }), ), ), ), ); expect(buildCount, 1); // Dispatch action that changes state store.dispatch(IncrementAction()); await tester.pump(); await tester.pump(); // Widget should NOT rebuild because it only accessed env, not state expect(buildCount, 1); // Dispatch another action store.dispatch(IncrementAction()); await tester.pump(); await tester.pump(); // Still no rebuild expect(buildCount, 1); }); testWidgets('Environment access combined with state access', (WidgetTester tester) async { final initialState = AppState(counter: 0); final environment = TestEnvironment( apiUrl: 'https://api.example.com', apiKey: 'test-key-123', ); final store = Store( initialState: initialState, environment: environment, ); int envOnlyBuildCount = 0; int stateAndEnvBuildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ // Widget that only accesses env Builder(builder: (context) { envOnlyBuildCount++; var env = context.env; return Text('URL: ${env.apiUrl}'); }), // Widget that accesses both env and state Builder(builder: (context) { stateAndEnvBuildCount++; var env = context.env; var counter = context.select((st) => st.counter); return Text('${env.apiKey}: $counter'); }), ], ), ), ), ), ); expect(envOnlyBuildCount, 1); expect(stateAndEnvBuildCount, 1); // Dispatch action store.dispatch(IncrementAction()); await tester.pump(); await tester.pump(); // Only the widget with state access should rebuild expect(envOnlyBuildCount, 1); // No rebuild expect(stateAndEnvBuildCount, 2); // Rebuilt due to state change }); testWidgets('Environment is same instance across widgets', (WidgetTester tester) async { final initialState = AppState(counter: 0); final environment = TestEnvironment( apiUrl: 'https://api.example.com', apiKey: 'test-key-123', ); final store = Store( initialState: initialState, environment: environment, ); TestEnvironment? env1; TestEnvironment? env2; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ Builder(builder: (context) { env1 = context.env; return const Text('Widget 1'); }), Builder(builder: (context) { env2 = context.env; return const Text('Widget 2'); }), ], ), ), ), ), ); expect(env1, isNotNull); expect(env2, isNotNull); expect(identical(env1, env2), true); expect(identical(env1, environment), true); }); testWidgets('Null environment returns null', (WidgetTester tester) async { final initialState = AppState(counter: 0); // Store without environment final store = Store(initialState: initialState); Object? capturedEnv; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { capturedEnv = context.getEnvironment(); return Text('Env: $capturedEnv'); }), ), ), ), ); expect(capturedEnv, isNull); expect(find.text('Env: null'), findsOneWidget); }); testWidgets('Environment accessible in nested widgets', (WidgetTester tester) async { final initialState = AppState(counter: 0); final environment = TestEnvironment( apiUrl: 'https://api.example.com', apiKey: 'nested-test', ); final store = Store( initialState: initialState, environment: environment, ); TestEnvironment? deepNestedEnv; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Container( child: Column( children: [ Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Builder(builder: (context) { deepNestedEnv = context.env; return Text('Key: ${deepNestedEnv!.apiKey}'); }), ), ), ], ), ), ), ), ), ); expect(deepNestedEnv, isNotNull); expect(deepNestedEnv!.apiKey, 'nested-test'); expect(find.text('Key: nested-test'), findsOneWidget); }); }); } // Extension for BuildContext extension BuildContextExtension on BuildContext { AppState get state => getState(); R select(R Function(AppState state) selector) => getSelect(selector); TestEnvironment get env => getEnvironment() as TestEnvironment; } // Test environment class class TestEnvironment { final String apiUrl; final String apiKey; TestEnvironment({required this.apiUrl, required this.apiKey}); } // Test state class class AppState { final int counter; AppState({required this.counter}); AppState copyWith({int? counter}) { return AppState(counter: counter ?? this.counter); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppState && other.counter == counter; } @override int get hashCode => counter.hashCode; } // Test action class IncrementAction extends ReduxAction { @override AppState reduce() { return state.copyWith(counter: state.counter + 1); } } ================================================ FILE: test/context_event_test.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('AsyncRedux context.event() functionality', () { testWidgets('Basic event consumption - value-less event (Event)', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); int buildCount = 0; bool? lastEventValue; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; var clearText = context.event((state) => state.clearTextEvt); lastEventValue = clearText; return Text('Clear: $clearText', key: const Key('clearText')); }), ), ), ), ); // Initial build - event is spent, should return false expect(buildCount, 1); expect(lastEventValue, false); expect(find.text('Clear: false'), findsOneWidget); // Dispatch event - should rebuild and return true store.dispatch(ClearTextAction()); await tester.pump(); await tester.pump(); expect(buildCount, 2); expect(lastEventValue, true); expect(find.text('Clear: true'), findsOneWidget); // Event is now spent - dispatching unrelated action should NOT rebuild // because the selected event hasn't changed store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCount, 2); // No rebuild - event didn't change expect(find.text('Clear: true'), findsOneWidget); // Still shows last rendered value }); testWidgets('Typed event consumption - Event', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); int buildCount = 0; String? lastEventValue; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; var newText = context.event((state) => state.changeTextEvt); lastEventValue = newText; return Text('Text: ${newText ?? "none"}', key: const Key('changeText')); }), ), ), ), ); // Initial build - event is spent, should return null expect(buildCount, 1); expect(lastEventValue, null); expect(find.text('Text: none'), findsOneWidget); // Dispatch event with value - should rebuild and return value store.dispatch(ChangeTextAction('Hello World')); await tester.pump(); await tester.pump(); expect(buildCount, 2); expect(lastEventValue, 'Hello World'); expect(find.text('Text: Hello World'), findsOneWidget); // Event is now spent - dispatching unrelated action should NOT rebuild store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCount, 2); // No rebuild - event didn't change expect(find.text('Text: Hello World'), findsOneWidget); // Still shows last value }); testWidgets('Event consumed only once per dispatch', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); List eventHistory = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var newText = context.event((state) => state.changeTextEvt); eventHistory.add(newText); return Text('Text: ${newText ?? "none"}'); }), ), ), ), ); expect(eventHistory, [null]); // Initial - spent // First event store.dispatch(ChangeTextAction('First')); await tester.pump(); await tester.pump(); expect(eventHistory, [null, 'First']); // Second event - overwrites the spent first event store.dispatch(ChangeTextAction('Second')); await tester.pump(); await tester.pump(); expect(eventHistory, [null, 'First', 'Second']); // Third event store.dispatch(ChangeTextAction('Third')); await tester.pump(); await tester.pump(); expect(eventHistory, [null, 'First', 'Second', 'Third']); }); testWidgets('Multiple events in same widget', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; var clear = context.event((state) => state.clearTextEvt); var change = context.event((state) => state.changeTextEvt); return Column( children: [ Text('Clear: $clear'), Text('Change: ${change ?? "none"}'), ], ); }), ), ), ), ); expect(buildCount, 1); expect(find.text('Clear: false'), findsOneWidget); expect(find.text('Change: none'), findsOneWidget); // Dispatch clear event only store.dispatch(ClearTextAction()); await tester.pump(); await tester.pump(); expect(buildCount, 2); expect(find.text('Clear: true'), findsOneWidget); expect(find.text('Change: none'), findsOneWidget); // Dispatch change event only store.dispatch(ChangeTextAction('New Text')); await tester.pump(); await tester.pump(); expect(buildCount, 3); expect(find.text('Clear: false'), findsOneWidget); // Clear was consumed expect(find.text('Change: New Text'), findsOneWidget); // Dispatch both events store.dispatch(ClearTextAction()); store.dispatch(ChangeTextAction('Both Events')); await tester.pump(); await tester.pump(); expect(buildCount, 4); expect(find.text('Clear: true'), findsOneWidget); expect(find.text('Change: Both Events'), findsOneWidget); }); testWidgets('Event triggers rebuild even with same value', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); int buildCount = 0; List values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; var newText = context.event((state) => state.changeTextEvt); values.add(newText); return Text('Text: ${newText ?? "none"}'); }), ), ), ), ); expect(buildCount, 1); // Dispatch event with value store.dispatch(ChangeTextAction('Same')); await tester.pump(); await tester.pump(); expect(buildCount, 2); expect(values.last, 'Same'); // Dispatch same value again - should still trigger rebuild // because each Event instance is unique store.dispatch(ChangeTextAction('Same')); await tester.pump(); await tester.pump(); expect(buildCount, 3); expect(values.last, 'Same'); // Dispatch same value a third time store.dispatch(ChangeTextAction('Same')); await tester.pump(); await tester.pump(); expect(buildCount, 4); expect(values.last, 'Same'); }); testWidgets('Event with null value vs spent event', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); List values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var newText = context.event((state) => state.changeTextEvt); values.add(newText); return Text('Text: ${newText ?? "none"}'); }), ), ), ), ); expect(values, [null]); // Spent event returns null // Dispatch event with null value store.dispatch(ChangeTextAction(null)); await tester.pump(); await tester.pump(); // Event with null value also returns null, but event was consumed expect(values, [null, null]); // Dispatch another event with actual value store.dispatch(ChangeTextAction('actual')); await tester.pump(); await tester.pump(); expect(values, [null, null, 'actual']); }); testWidgets('Event with various types - int', (WidgetTester tester) async { final initialState = AppStateWithIntEvent( numberEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); List values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var number = context.getEvent( (state) => state.numberEvt); values.add(number); return Text('Number: ${number ?? "none"}'); }), ), ), ), ); expect(values, [null]); store.dispatch(SetNumberAction(42)); await tester.pump(); await tester.pump(); expect(values, [null, 42]); store.dispatch(SetNumberAction(0)); await tester.pump(); await tester.pump(); expect(values, [null, 42, 0]); }); testWidgets('Event not consumed when widget disposed', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); bool showWidget = true; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: StatefulBuilder( builder: (context, setState) { return Scaffold( body: Column( children: [ if (showWidget) Builder(builder: (context) { var newText = context.event((state) => state.changeTextEvt); return Text('Text: ${newText ?? "none"}'); }), ElevatedButton( onPressed: () => setState(() => showWidget = false), child: const Text('Hide'), ), ], ), ); }, ), ), ), ); expect(find.text('Text: none'), findsOneWidget); // Dispatch event store.dispatch(ChangeTextAction('Hello')); await tester.pump(); await tester.pump(); expect(find.text('Text: Hello'), findsOneWidget); // Hide widget await tester.tap(find.text('Hide')); await tester.pump(); expect(find.text('Text: Hello'), findsNothing); // Dispatch another event while widget is hidden store.dispatch(ChangeTextAction('World')); await tester.pump(); // Event is in state but not consumed (no widget to consume it) expect(store.state.changeTextEvt.isNotSpent, true); }); testWidgets('Rapid event dispatching', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); List values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var newText = context.event((state) => state.changeTextEvt); values.add(newText); return Text('Text: ${newText ?? "none"}'); }), ), ), ), ); expect(values, [null]); // Dispatch multiple events rapidly store.dispatch(ChangeTextAction('First')); store.dispatch(ChangeTextAction('Second')); store.dispatch(ChangeTextAction('Third')); await tester.pump(); await tester.pump(); // Only the last event should be consumed (events overwrite each other) expect(values.last, 'Third'); }); testWidgets('Event with complex type - List', (WidgetTester tester) async { final initialState = AppStateWithListEvent( itemsEvt: Event>.spent(), counter: 0, ); final store = Store(initialState: initialState); List?> values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var items = context.getEvent>( (state) => state.itemsEvt); values.add(items); return Text('Items: ${items?.join(", ") ?? "none"}'); }), ), ), ), ); expect(values, [null]); expect(find.text('Items: none'), findsOneWidget); store.dispatch(SetItemsAction(['apple', 'banana', 'cherry'])); await tester.pump(); await tester.pump(); expect(values.last, ['apple', 'banana', 'cherry']); expect(find.text('Items: apple, banana, cherry'), findsOneWidget); }); testWidgets('Event combined with select - independent rebuilds', (WidgetTester tester) async { final initialState = AppState( clearTextEvt: Event.spent(), changeTextEvt: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); int eventWidgetBuilds = 0; int selectWidgetBuilds = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ // Widget using event Builder(builder: (context) { eventWidgetBuilds++; var newText = context.event((state) => state.changeTextEvt); return Text('Event: ${newText ?? "none"}'); }), // Widget using select Builder(builder: (context) { selectWidgetBuilds++; var counter = context.select((st) => st.counter); return Text('Counter: $counter'); }), ], ), ), ), ), ); expect(eventWidgetBuilds, 1); expect(selectWidgetBuilds, 1); // Dispatch event - should rebuild event widget store.dispatch(ChangeTextAction('Hello')); await tester.pump(); await tester.pump(); expect(eventWidgetBuilds, 2); // Select widget may or may not rebuild depending on implementation // Increment counter - should rebuild select widget store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(selectWidgetBuilds, greaterThan(1)); }); testWidgets('Event.from - consuming from multiple events', (WidgetTester tester) async { final initialState = AppStateWithTwoEvents( event1: Event.spent(), event2: Event.spent(), counter: 0, ); final store = Store(initialState: initialState); List values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var combined = context.getEvent( (state) => Event.from(state.event1, state.event2)); values.add(combined); return Text('Combined: ${combined ?? "none"}'); }), ), ), ), ); expect(values, [null]); // Dispatch to first event store.dispatch(SetEvent1Action('From Event 1')); await tester.pump(); await tester.pump(); expect(values.last, 'From Event 1'); // Dispatch to second event (first is now spent, so second is consumed) store.dispatch(SetEvent2Action('From Event 2')); await tester.pump(); await tester.pump(); expect(values.last, 'From Event 2'); // Dispatch to first event again store.dispatch(SetEvent1Action('From Event 1 Again')); await tester.pump(); await tester.pump(); expect(values.last, 'From Event 1 Again'); }); testWidgets('MappedEvent - transforming event values', (WidgetTester tester) async { final initialState = AppStateWithMappedEvent( indexEvt: Event.spent(), users: ['Alice', 'Bob', 'Charlie'], counter: 0, ); final store = Store(initialState: initialState); List values = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { var user = context.getEvent( (state) => Event.map( state.indexEvt, (int? index) => index == null ? null : state.users[index])); values.add(user); return Text('User: ${user ?? "none"}'); }), ), ), ), ); expect(values, [null]); // Dispatch index 1 -> should return "Bob" store.dispatch(SetIndexAction(1)); await tester.pump(); await tester.pump(); expect(values.last, 'Bob'); expect(find.text('User: Bob'), findsOneWidget); // Dispatch index 2 -> should return "Charlie" store.dispatch(SetIndexAction(2)); await tester.pump(); await tester.pump(); expect(values.last, 'Charlie'); expect(find.text('User: Charlie'), findsOneWidget); // Dispatch index 0 -> should return "Alice" store.dispatch(SetIndexAction(0)); await tester.pump(); await tester.pump(); expect(values.last, 'Alice'); expect(find.text('User: Alice'), findsOneWidget); }); testWidgets('Event equality - spent events are equal', (WidgetTester tester) async { final evt1 = Event.spent(); final evt2 = Event.spent(); expect(evt1 == evt2, true); final evt3 = Event('value'); final evt4 = Event('value'); // Unspent events are never equal expect(evt3 == evt4, false); // Consume evt3 evt3.consume(); // Now evt3 is spent but evt4 is not expect(evt3 == evt4, false); // Consume evt4 evt4.consume(); // Both spent, should be equal expect(evt3 == evt4, true); }); testWidgets('Event isSpent and isNotSpent properties', (WidgetTester tester) async { final evt = Event('test'); expect(evt.isSpent, false); expect(evt.isNotSpent, true); evt.consume(); expect(evt.isSpent, true); expect(evt.isNotSpent, false); }); }); } // Extension for BuildContext extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } // Test state classes class AppState { final Event clearTextEvt; final Event changeTextEvt; final int counter; AppState({ required this.clearTextEvt, required this.changeTextEvt, required this.counter, }); AppState copyWith({ Event? clearTextEvt, Event? changeTextEvt, int? counter, }) { return AppState( clearTextEvt: clearTextEvt ?? this.clearTextEvt, changeTextEvt: changeTextEvt ?? this.changeTextEvt, counter: counter ?? this.counter, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppState && other.clearTextEvt == clearTextEvt && other.changeTextEvt == changeTextEvt && other.counter == counter; } @override int get hashCode => Object.hash(clearTextEvt, changeTextEvt, counter); } class AppStateWithIntEvent { final Event numberEvt; final int counter; AppStateWithIntEvent({required this.numberEvt, required this.counter}); AppStateWithIntEvent copyWith({Event? numberEvt, int? counter}) { return AppStateWithIntEvent( numberEvt: numberEvt ?? this.numberEvt, counter: counter ?? this.counter, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppStateWithIntEvent && other.numberEvt == numberEvt && other.counter == counter; } @override int get hashCode => Object.hash(numberEvt, counter); } class AppStateWithListEvent { final Event> itemsEvt; final int counter; AppStateWithListEvent({required this.itemsEvt, required this.counter}); AppStateWithListEvent copyWith({Event>? itemsEvt, int? counter}) { return AppStateWithListEvent( itemsEvt: itemsEvt ?? this.itemsEvt, counter: counter ?? this.counter, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppStateWithListEvent && other.itemsEvt == itemsEvt && other.counter == counter; } @override int get hashCode => Object.hash(itemsEvt, counter); } class AppStateWithTwoEvents { final Event event1; final Event event2; final int counter; AppStateWithTwoEvents({ required this.event1, required this.event2, required this.counter, }); AppStateWithTwoEvents copyWith({ Event? event1, Event? event2, int? counter, }) { return AppStateWithTwoEvents( event1: event1 ?? this.event1, event2: event2 ?? this.event2, counter: counter ?? this.counter, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppStateWithTwoEvents && other.event1 == event1 && other.event2 == event2 && other.counter == counter; } @override int get hashCode => Object.hash(event1, event2, counter); } class AppStateWithMappedEvent { final Event indexEvt; final List users; final int counter; AppStateWithMappedEvent({ required this.indexEvt, required this.users, required this.counter, }); AppStateWithMappedEvent copyWith({ Event? indexEvt, List? users, int? counter, }) { return AppStateWithMappedEvent( indexEvt: indexEvt ?? this.indexEvt, users: users ?? this.users, counter: counter ?? this.counter, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppStateWithMappedEvent && other.indexEvt == indexEvt && other.counter == counter; } @override int get hashCode => Object.hash(indexEvt, counter); } // Test actions class ClearTextAction extends ReduxAction { @override AppState reduce() { return state.copyWith(clearTextEvt: Event()); } } class ChangeTextAction extends ReduxAction { final String? text; ChangeTextAction(this.text); @override AppState reduce() { return state.copyWith(changeTextEvt: Event(text)); } } class IncrementCounterAction extends ReduxAction { @override AppState reduce() { return state.copyWith(counter: state.counter + 1); } } class SetNumberAction extends ReduxAction { final int number; SetNumberAction(this.number); @override AppStateWithIntEvent reduce() { return state.copyWith(numberEvt: Event(number)); } } class SetItemsAction extends ReduxAction { final List items; SetItemsAction(this.items); @override AppStateWithListEvent reduce() { return state.copyWith(itemsEvt: Event>(items)); } } class SetEvent1Action extends ReduxAction { final String value; SetEvent1Action(this.value); @override AppStateWithTwoEvents reduce() { return state.copyWith(event1: Event(value)); } } class SetEvent2Action extends ReduxAction { final String value; SetEvent2Action(this.value); @override AppStateWithTwoEvents reduce() { return state.copyWith(event2: Event(value)); } } class IncrementCounter2Action extends ReduxAction { @override AppStateWithTwoEvents reduce() { return state.copyWith(counter: state.counter + 1); } } class SetIndexAction extends ReduxAction { final int index; SetIndexAction(this.index); @override AppStateWithMappedEvent reduce() { return state.copyWith(indexEvt: Event(index)); } } class IncrementCounter3Action extends ReduxAction { @override AppStateWithMappedEvent reduce() { return state.copyWith(counter: state.counter + 1); } } ================================================ FILE: test/context_select_advanced_test.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('AsyncRedux select() functionality', () { testWidgets('Basic selection - widget only rebuilds when selected value changes', (WidgetTester tester) async { // Create initial state final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(['item1']), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); // Track build counts for each widget Map buildCounts = { 'userName': 0, 'userAge': 0, 'counter': 0, 'itemsCount': 0, 'darkMode': 0, }; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ Builder(builder: (context) { buildCounts['userName'] = buildCounts['userName']! + 1; final userName = context.select((st) => st.user.name); return Text('Name: $userName', key: const Key('userName')); }), Builder(builder: (context) { buildCounts['userAge'] = buildCounts['userAge']! + 1; final userAge = context.select((st) => st.user.age); return Text('Age: $userAge', key: const Key('userAge')); }), Builder(builder: (context) { buildCounts['counter'] = buildCounts['counter']! + 1; final counter = context.select((st) => st.counter); return Text('Counter: $counter', key: const Key('counter')); }), ], ), ), ), ), ); // Initial build expect(buildCounts['userName'], 1); expect(buildCounts['userAge'], 1); expect(buildCounts['counter'], 1); expect(find.text('Name: Alice'), findsOneWidget); expect(find.text('Age: 25'), findsOneWidget); expect(find.text('Counter: 0'), findsOneWidget); // Update user name - only userName widget should rebuild store.dispatch(UpdateUserNameAction('Bob')); await tester.pump(); await tester.pump(); expect(buildCounts['userName'], 2); // Rebuilt expect(buildCounts['userAge'], 1); // Not rebuilt expect(buildCounts['counter'], 1); // Not rebuilt expect(find.text('Name: Bob'), findsOneWidget); // Update user age - only userAge widget should rebuild store.dispatch(UpdateUserAgeAction(30)); await tester.pump(); await tester.pump(); expect(buildCounts['userName'], 2); // Not rebuilt expect(buildCounts['userAge'], 2); // Rebuilt expect(buildCounts['counter'], 1); // Not rebuilt expect(find.text('Age: 30'), findsOneWidget); // Update counter - only counter widget should rebuild store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCounts['userName'], 2); // Not rebuilt expect(buildCounts['userAge'], 2); // Not rebuilt expect(buildCounts['counter'], 2); // Rebuilt expect(find.text('Counter: 1'), findsOneWidget); }); testWidgets('Deep equality checking for collections', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(['apple', 'banana']), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; // Select filtered list final filteredItems = context.select( (state) => state.items.where((item) => item.startsWith('a')).toList(), ); return Text('Items: ${filteredItems.join(', ')}'); }), ), ), ), ); expect(buildCount, 1); expect(find.text('Items: apple'), findsOneWidget); // Add item that doesn't match filter - should NOT rebuild store.dispatch(AddItemAction('cherry')); await tester.pump(); await tester.pump(); expect(buildCount, 1); // No rebuild // Add item that matches filter - should rebuild store.dispatch(AddItemAction('apricot')); await tester.pump(); await tester.pump(); expect(buildCount, 2); // Rebuilt expect(find.text('Items: apple, apricot'), findsOneWidget); }); testWidgets('Multiple selects in one widget', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; // Multiple selects in one build final userName = context.select((st) => st.user.name); final userAge = context.select((st) => st.user.age); final isDarkMode = context.select((st) => st.settings.darkMode); return Column( children: [ Text('User: $userName, $userAge'), Text('Dark Mode: $isDarkMode'), ], ); }), ), ), ), ); expect(buildCount, 1); // Change only counter (not selected) - should NOT rebuild store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCount, 1); // Change any selected value - should rebuild store.dispatch(UpdateUserNameAction('Bob')); await tester.pump(); await tester.pump(); expect(buildCount, 2); // Change another selected value - should rebuild store.dispatch(ToggleDarkModeAction()); await tester.pump(); await tester.pump(); expect(buildCount, 3); }); testWidgets('Complex computed values', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 17, email: 'alice@example.com'), counter: 5, items: IList(['a', 'b', 'c']), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; // Select computed summary. final summary = context.select((st) => { 'isAdult': st.user.age >= 18, 'hasMany': st.items.length > 5, 'score': st.counter * st.items.length, }); return Text('Adult: ${summary['isAdult']}, Many: ${summary['hasMany']}, Score: ${summary['score']}'); }), ), ), ), ); expect(buildCount, 1); expect(find.text('Adult: false, Many: false, Score: 15'), findsOneWidget); // Change age but still minor - computed value same, should NOT rebuild store.dispatch(UpdateUserAgeAction(16)); await tester.pump(); await tester.pump(); expect(buildCount, 1); // Change age to adult - computed value changes, should rebuild store.dispatch(UpdateUserAgeAction(18)); await tester.pump(); await tester.pump(); expect(buildCount, 2); expect(find.text('Adult: true, Many: false, Score: 15'), findsOneWidget); // Add items to change score store.dispatch(AddItemAction('d')); await tester.pump(); await tester.pump(); expect(buildCount, 3); expect(find.text('Adult: true, Many: false, Score: 20'), findsOneWidget); }); testWidgets('Selector clearing between builds', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); bool showAge = true; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: StatefulBuilder( builder: (context, setState) { return Column( children: [ if (showAge) Builder(builder: (context) { final userAge = context.select((st) => st.user.age); return Text('Age: $userAge'); }) else Builder(builder: (context) { final userName = context.select((st) => st.user.name); return Text('Name: $userName'); }), ElevatedButton( onPressed: () => setState(() => showAge = !showAge), child: const Text('Toggle'), ), ], ); }, ), ), ), ), ); expect(find.text('Age: 25'), findsOneWidget); expect(find.text('Name: Alice'), findsNothing); // Toggle to show name await tester.tap(find.text('Toggle')); await tester.pump(); await tester.pump(); // Extra pump for microtask expect(find.text('Age: 25'), findsNothing); expect(find.text('Name: Alice'), findsOneWidget); // Change age (should not trigger rebuild since we're now selecting name) store.dispatch(UpdateUserAgeAction(30)); await tester.pump(); expect(find.text('Name: Alice'), findsOneWidget); // Still showing name // Change name (should trigger rebuild) store.dispatch(UpdateUserNameAction('Bob')); await tester.pump(); expect(find.text('Name: Bob'), findsOneWidget); }); testWidgets('Comparing to regular state access', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); int regularBuildCount = 0; int selectBuildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ // Widget using regular state access Builder(builder: (context) { regularBuildCount++; final state = context.state; return Text('Regular: ${state.user.name}'); }), // Widget using select. Builder(builder: (context) { selectBuildCount++; final userName = context.select((st) => st.user.name); return Text('Select: $userName'); }), ], ), ), ), ), ); expect(regularBuildCount, 1); expect(selectBuildCount, 1); // Change unrelated state - regular rebuilds, select doesn't store.dispatch(IncrementCounterAction()); await tester.pump(); expect(regularBuildCount, 2); // Rebuilt expect(selectBuildCount, 1); // Not rebuilt // Change selected state - both rebuild store.dispatch(UpdateUserNameAction('Bob')); await tester.pump(); await tester.pump(); expect(regularBuildCount, 3); // Rebuilt expect(selectBuildCount, 2); // Rebuilt }); }); group('Error handling and edge cases', () { testWidgets('Using select outside build method throws error', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder( builder: (context) { return ElevatedButton( onPressed: () { // This should throw an error. expect( () => context.select((st) => st.user.name), throwsA(isA()), ); }, child: const Text('Click me'), ); }, ), ), ), ), ); await tester.tap(find.text('Click me')); }); testWidgets('Nested select calls throw error', (WidgetTester tester) async { final initialState = AppState( user: User(name: 'Alice', age: 25, email: 'alice@example.com'), counter: 0, items: IList(), settings: Settings(darkMode: false, language: 'en'), ); final store = Store(initialState: initialState); bool errorThrown = false; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder( builder: (context) { try { context.select((st) { // Nested select - should throw. context.select((s) => s.counter); return st.user.name; }); } catch (e) { errorThrown = true; return const Text('Error caught'); } return const Text('No error'); }, ), ), ), ), ); expect(errorThrown, true); expect(find.text('Error caught'), findsOneWidget); }); }); } // Recommended to create this extension. extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); } // Test state classes class AppState { final User user; final int counter; final IList items; final Settings settings; AppState({ required this.user, required this.counter, required this.items, required this.settings, }); AppState copyWith({ User? user, int? counter, IList? items, Settings? settings, }) { return AppState( user: user ?? this.user, counter: counter ?? this.counter, items: items ?? this.items, settings: settings ?? this.settings, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppState && other.user == user && other.counter == counter && other.items == items && other.settings == settings; } @override int get hashCode => Object.hash(user, counter, items, settings); } class User { final String name; final int age; final String email; User({required this.name, required this.age, required this.email}); User copyWith({String? name, int? age, String? email}) { return User( name: name ?? this.name, age: age ?? this.age, email: email ?? this.email, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is User && other.name == name && other.age == age && other.email == email; } @override int get hashCode => Object.hash(name, age, email); } class Settings { final bool darkMode; final String language; Settings({required this.darkMode, required this.language}); Settings copyWith({bool? darkMode, String? language}) { return Settings( darkMode: darkMode ?? this.darkMode, language: language ?? this.language, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is Settings && other.darkMode == darkMode && other.language == language; } @override int get hashCode => Object.hash(darkMode, language); } // Test actions class UpdateUserNameAction extends ReduxAction { final String name; UpdateUserNameAction(this.name); @override AppState reduce() { return state.copyWith(user: state.user.copyWith(name: name)); } } class UpdateUserAgeAction extends ReduxAction { final int age; UpdateUserAgeAction(this.age); @override AppState reduce() { return state.copyWith(user: state.user.copyWith(age: age)); } } class IncrementCounterAction extends ReduxAction { @override AppState reduce() { return state.copyWith(counter: state.counter + 1); } } class AddItemAction extends ReduxAction { final String item; AddItemAction(this.item); @override AppState reduce() { return state.copyWith(items: state.items.add(item)); } } class ToggleDarkModeAction extends ReduxAction { @override AppState reduce() { return state.copyWith( settings: state.settings.copyWith(darkMode: !state.settings.darkMode), ); } } // Test widgets class SelectTestWidget extends StatefulWidget { final Store store; final int Function()? onBuildCounter; const SelectTestWidget({ Key? key, required this.store, this.onBuildCounter, }) : super(key: key); @override State createState() => _SelectTestWidgetState(); } class _SelectTestWidgetState extends State { int buildCount = 0; @override Widget build(BuildContext context) { buildCount++; widget.onBuildCounter?.call(); return StoreProvider( store: widget.store, child: Builder( builder: (context) { return Column( children: [ UserNameWidget( key: const Key('userName'), onBuild: widget.onBuildCounter, ), UserAgeWidget( key: const Key('userAge'), onBuild: widget.onBuildCounter, ), CounterWidget( key: const Key('counter'), onBuild: widget.onBuildCounter, ), ItemsCountWidget( key: const Key('itemsCount'), onBuild: widget.onBuildCounter, ), DarkModeWidget( key: const Key('darkMode'), onBuild: widget.onBuildCounter, ), ], ); }, ), ); } } class UserNameWidget extends StatelessWidget { final Function()? onBuild; const UserNameWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); final userName = context.select((st) => st.user.name); return Text('Name: $userName'); } } class UserAgeWidget extends StatelessWidget { final Function()? onBuild; const UserAgeWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); final userAge = context.select((st) => st.user.age); return Text('Age: $userAge'); } } class CounterWidget extends StatelessWidget { final Function()? onBuild; const CounterWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); final counter = context.select((st) => st.counter); return Text('Counter: $counter'); } } class ItemsCountWidget extends StatelessWidget { final Function()? onBuild; const ItemsCountWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); final itemCount = context.select((st) => st.items.length); return Text('Items: $itemCount'); } } class DarkModeWidget extends StatelessWidget { final Function()? onBuild; const DarkModeWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); final isDarkMode = context.select((st) => st.settings.darkMode); return Text('Dark Mode: $isDarkMode'); } } // Widget that uses multiple selects class MultiSelectWidget extends StatelessWidget { final Function()? onBuild; const MultiSelectWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); // Multiple selects in one build final userName = context.select((st) => st.user.name); final userAge = context.select((st) => st.user.age); final isDarkMode = context.select((st) => st.settings.darkMode); return Column( children: [ Text('User: $userName, $userAge'), Text('Dark Mode: $isDarkMode'), ], ); } } // Widget that selects complex computed values class ComputedSelectWidget extends StatelessWidget { final Function()? onBuild; const ComputedSelectWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); // Select computed/derived values final summary = context.select((st) => { 'userName': st.user.name, 'itemCount': st.items.length, 'isAdult': st.user.age >= 18, }); return Text('Summary: $summary'); } } // Widget that selects lists class ListSelectWidget extends StatelessWidget { final Function()? onBuild; const ListSelectWidget({Key? key, this.onBuild}) : super(key: key); @override Widget build(BuildContext context) { onBuild?.call(); // Select filtered list final longItems = context.select( (state) => state.items.where((item) => item.length > 5).toList(), ); return Text('Long items: ${longItems.join(', ')}'); } } ================================================ FILE: test/context_select_test.dart ================================================ // Exploratory test to debug the select() rebuild issue import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Select Debug Investigation', () { testWidgets('Track dependency lifecycle and rebuild behavior', (WidgetTester tester) async { // print('\n' + '=' * 80); print('STARTING SELECT DEBUG TEST'); print('=' * 80 + '\n'); // Create initial state final initialState = TestState(counter: 0, text: 'hello', flag: false); final store = Store(initialState: initialState); // Track builds final counterBuilds = []; final flagBuilds = []; final regularBuilds = []; print('>>> INITIAL WIDGET TREE BUILD\n'); // Build the widget tree await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ CounterSelectWidget(buildLog: counterBuilds), FlagSelectWidget(buildLog: flagBuilds), RegularStateWidget(buildLog: regularBuilds), ], ), ), ), ), ); await tester.pump(); await tester.pump(); print('\n>>> INITIAL BUILD COMPLETE'); print('Counter builds: ${counterBuilds.length}'); print('Flag builds: ${flagBuilds.length}'); print('Regular builds: ${regularBuilds.length}'); expect(counterBuilds.length, 1); expect(flagBuilds.length, 1); expect(regularBuilds.length, 1); // Clear build logs for next phase counterBuilds.clear(); flagBuilds.clear(); regularBuilds.clear(); // --------------- print('\n' + '-' * 80); print('>>> ACTION 1: INCREMENT COUNTER'); print('-' * 80 + '\n'); // Dispatch increment action store.dispatch(IncrementAction()); await tester.pump(); await tester.pump(); print('\n>>> AFTER INCREMENT:'); print( 'Counter rebuilds: ${counterBuilds.length} ${counterBuilds.isNotEmpty ? "✓" : "✗"}'); print( 'Flag rebuilds: ${flagBuilds.length} ${flagBuilds.isEmpty ? "✓" : "✗ UNEXPECTED!"}'); print( 'Regular rebuilds: ${regularBuilds.length} ${regularBuilds.isNotEmpty ? "✓" : "✗"}'); expect(counterBuilds.length, 1); expect(flagBuilds.length, 0); expect(regularBuilds.length, 1); // Clear for next action counterBuilds.clear(); flagBuilds.clear(); regularBuilds.clear(); // --------------- print('\n' + '-' * 80); print('>>> ACTION 2: TOGGLE FLAG'); print('-' * 80 + '\n'); // Dispatch toggle action store.dispatch(ToggleFlagAction()); await tester.pump(); await tester.pump(); print('\n>>> AFTER TOGGLE:'); print( 'Counter rebuilds: ${counterBuilds.length} ${counterBuilds.isEmpty ? "✓" : "✗ UNEXPECTED!"}'); print( 'Flag rebuilds: ${flagBuilds.length} ${flagBuilds.isNotEmpty ? "✓" : "✗"}'); print( 'Regular rebuilds: ${regularBuilds.length} ${regularBuilds.isNotEmpty ? "✓" : "✗"}'); expect(counterBuilds.length, 0); expect(flagBuilds.length, 1); expect(regularBuilds.length, 1); // Clear for next action counterBuilds.clear(); flagBuilds.clear(); regularBuilds.clear(); // --------------- print('\n' + '-' * 80); print('>>> ACTION 3: INCREMENT AGAIN'); print('-' * 80 + '\n'); // Dispatch another increment store.dispatch(IncrementAction()); await tester.pump(); await tester.pump(); print('\n>>> AFTER SECOND INCREMENT:'); print( 'Counter rebuilds: ${counterBuilds.length} ${counterBuilds.isNotEmpty ? "✓" : "✗"}'); print( 'Flag rebuilds: ${flagBuilds.length} ${flagBuilds.isEmpty ? "✓" : "✗ UNEXPECTED!"}'); print( 'Regular rebuilds: ${regularBuilds.length} ${regularBuilds.isNotEmpty ? "✓" : "✗"}'); expect(counterBuilds.length, 1); expect(flagBuilds.length, 0); expect(regularBuilds.length, 1); // --------------- print('\n' + '-' * 80); print('TEST COMPLETE'); print('-' * 80 + '\n'); }); testWidgets('Select works with inline Builder widgets', (WidgetTester tester) async { // Enable debug logging print('\n' + '=' * 80); print('TESTING DIFFERENT WIDGET PATTERNS'); print('=' * 80 + '\n'); final initialState = TestState(counter: 0, text: 'hello', flag: false); final store = Store(initialState: initialState); // Track builds final counterBuilds = []; final flagBuilds = []; // Test with Builder widgets await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ // Pattern 1: Direct widget Builder( builder: (context) { counterBuilds.add('counter'); print('[BUILDER 1] Building counter selector'); final counter = context.select((s) => s.counter); return Text('Direct: $counter'); }, ), // Pattern 2: Wrapped in another Builder Builder( builder: (context) => Builder( builder: (context) { flagBuilds.add('flag'); print('[BUILDER 2] Building flag selector'); final flag = context.select((s) => s.flag); return Text('Nested: $flag'); }, ), ), ], ), ), ), ), ); await tester.pump(); await tester.pump(); // Initial build expect(counterBuilds.length, 1); expect(flagBuilds.length, 1); counterBuilds.clear(); flagBuilds.clear(); print('\n>>> DISPATCHING INCREMENT IN BUILDER TEST'); store.dispatch(IncrementAction()); await tester.pump(); await tester.pump(); // Only counter should rebuild expect(counterBuilds.length, 1); expect(flagBuilds.length, 0); counterBuilds.clear(); flagBuilds.clear(); print('\n>>> DISPATCHING TOGGLE IN BUILDER TEST'); store.dispatch(ToggleFlagAction()); await tester.pump(); await tester.pump(); // Only flag should rebuild expect(counterBuilds.length, 0); expect(flagBuilds.length, 1); print('\n' + '-' * 80); print('BUILDER TEST COMPLETE'); print('-' * 80 + '\n'); }); }); } // Recommended to create this extension. extension BuildContextExtension on BuildContext { R select(R Function(TestState state) selector) => getSelect(selector); } // Simple test state class TestState { final int counter; final String text; final bool flag; TestState({ required this.counter, required this.text, required this.flag, }); TestState copyWith({ int? counter, String? text, bool? flag, }) { return TestState( counter: counter ?? this.counter, text: text ?? this.text, flag: flag ?? this.flag, ); } @override String toString() => 'TestState(counter: $counter, text: $text, flag: $flag)'; @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is TestState && other.counter == counter && other.text == text && other.flag == flag; } @override int get hashCode => Object.hash(counter, text, flag); } // Test actions ------------------- class IncrementAction extends ReduxAction { @override TestState reduce() => state.copyWith(counter: state.counter + 1); } class ChangeTextAction extends ReduxAction { final String text; ChangeTextAction(this.text); @override TestState reduce() => state.copyWith(text: text); } class ToggleFlagAction extends ReduxAction { @override TestState reduce() => state.copyWith(flag: !state.flag); } // Test widgets with tracking ------------------- class CounterSelectWidget extends StatelessWidget { final List buildLog; const CounterSelectWidget({Key? key, required this.buildLog}) : super(key: key); @override Widget build(BuildContext context) { buildLog.add('CounterSelectWidget.build()'); print('\n=== CounterSelectWidget BUILD ==='); final counter = context.select((st) { print(' [Selector executing] Selecting counter: ${st.counter}'); return st.counter; }); print(' Selected value: $counter'); print('=== CounterSelectWidget BUILD END ===\n'); return Text('Counter: $counter'); } } class FlagSelectWidget extends StatelessWidget { final List buildLog; const FlagSelectWidget({Key? key, required this.buildLog}) : super(key: key); @override Widget build(BuildContext context) { buildLog.add('FlagSelectWidget.build()'); print('\n=== FlagSelectWidget BUILD ==='); final flag = context.select((st) { print(' [Selector executing] Selecting flag: ${st.flag}'); return st.flag; }); print(' Selected value: $flag'); print('=== FlagSelectWidget BUILD END ===\n'); return Text('Flag: $flag'); } } class RegularStateWidget extends StatelessWidget { final List buildLog; const RegularStateWidget({Key? key, required this.buildLog}) : super(key: key); @override Widget build(BuildContext context) { buildLog.add('RegularStateWidget.build()'); print('\n=== RegularStateWidget BUILD ==='); final state = context.getState(); print(' Got state: $state'); print('=== RegularStateWidget BUILD END ===\n'); return Text('State: $state'); } } ================================================ FILE: test/context_state_test.dart ================================================ // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('AsyncRedux context.state functionality', () { testWidgets('context.state rebuilds on any state change', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; var state = context.state; return Text('Name: ${state.name}'); }), ), ), ), ); expect(buildCount, 1); expect(find.text('Name: Alice'), findsOneWidget); // Change name - should rebuild store.dispatch(ChangeNameAction('Bob')); await tester.pump(); await tester.pump(); expect(buildCount, 2); expect(find.text('Name: Bob'), findsOneWidget); // Change counter (unrelated to displayed value) - should still rebuild store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCount, 3); // Change flag (also unrelated) - should still rebuild store.dispatch(ToggleFlagAction()); await tester.pump(); await tester.pump(); expect(buildCount, 4); }); testWidgets( 'context.state always rebuilds even when accessing single field', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; // Only accessing name, but using context.state var name = context.state.name; return Text('Name: $name'); }), ), ), ), ); expect(buildCount, 1); // Change counter (not name) - should still rebuild because we used context.state store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCount, 2); // Compare with select - only rebuilds when selected value changes // (This is tested in context_select_test.dart) }); }); group('AsyncRedux context.read() functionality', () { testWidgets('context.read() does not trigger rebuilds', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); int buildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { buildCount++; var state = context.read(); return Text('Name: ${state.name}'); }), ), ), ), ); expect(buildCount, 1); expect(find.text('Name: Alice'), findsOneWidget); // Change name - should NOT rebuild store.dispatch(ChangeNameAction('Bob')); await tester.pump(); await tester.pump(); expect(buildCount, 1); // Still shows old value because widget didn't rebuild expect(find.text('Name: Alice'), findsOneWidget); // Change counter - should NOT rebuild store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(buildCount, 1); // Change flag - should NOT rebuild store.dispatch(ToggleFlagAction()); await tester.pump(); await tester.pump(); expect(buildCount, 1); }); testWidgets('context.read() can be used in initState', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 42, flag: false, ); final store = Store(initialState: initialState); int? capturedCounter; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: InitStateTestWidget( onInitState: (context) { // This should work - reading state in initState capturedCounter = context.read().counter; }, ), ), ), ), ); expect(capturedCounter, 42); }); testWidgets('context.read() returns current state value', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); List readValues = []; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { return ElevatedButton( onPressed: () { // Read current state on button press readValues.add(context.read().name); }, child: const Text('Read'), ); }), ), ), ), ); // Read initial value await tester.tap(find.text('Read')); expect(readValues, ['Alice']); // Change state store.dispatch(ChangeNameAction('Bob')); await tester.pump(); await tester.pump(); // Read new value await tester.tap(find.text('Read')); expect(readValues, ['Alice', 'Bob']); // Change again store.dispatch(ChangeNameAction('Charlie')); await tester.pump(); await tester.pump(); // Read newest value await tester.tap(find.text('Read')); expect(readValues, ['Alice', 'Bob', 'Charlie']); }); }); group('context.state vs context.read() comparison', () { testWidgets('state rebuilds, read does not', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); int stateBuildCount = 0; int readBuildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ // Widget using context.state Builder(builder: (context) { stateBuildCount++; var state = context.state; return Text('State: ${state.name}'); }), // Widget using context.read() Builder(builder: (context) { readBuildCount++; var state = context.read(); return Text('Read: ${state.name}'); }), ], ), ), ), ), ); expect(stateBuildCount, 1); expect(readBuildCount, 1); // Change state store.dispatch(ChangeNameAction('Bob')); await tester.pump(); await tester.pump(); expect(stateBuildCount, 2); // Rebuilt expect(readBuildCount, 1); // Not rebuilt // Change again store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); expect(stateBuildCount, 3); // Rebuilt again expect(readBuildCount, 1); // Still not rebuilt }); testWidgets('Multiple dispatches - state rebuilds each time, read never', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); int stateBuildCount = 0; int readBuildCount = 0; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Column( children: [ Builder(builder: (context) { stateBuildCount++; return Text('Counter: ${context.state.counter}'); }), Builder(builder: (context) { readBuildCount++; return Text('Read Counter: ${context.read().counter}'); }), ], ), ), ), ), ); expect(stateBuildCount, 1); expect(readBuildCount, 1); // Dispatch 5 actions for (int i = 0; i < 5; i++) { store.dispatch(IncrementCounterAction()); await tester.pump(); await tester.pump(); } expect(stateBuildCount, 6); // 1 initial + 5 rebuilds expect(readBuildCount, 1); // Never rebuilt }); }); group('Edge cases and error handling', () { testWidgets('context.state throws when used in initState', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); Object? caughtError; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: InitStateTestWidget( onInitState: (context) { try { // This should throw - using context.state in initState context.state; } catch (e) { caughtError = e; } }, ), ), ), ), ); // context.state should throw when used in initState expect(caughtError, isNotNull); }); testWidgets('context.read() works in callbacks', (WidgetTester tester) async { final initialState = AppState( name: 'Alice', counter: 0, flag: false, ); final store = Store(initialState: initialState); String? capturedName; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: store, child: Scaffold( body: Builder(builder: (context) { return ElevatedButton( onPressed: () { capturedName = context.read().name; }, child: const Text('Capture'), ); }), ), ), ), ); // Change state before pressing button store.dispatch(ChangeNameAction('Bob')); await tester.pump(); await tester.pump(); // Now press button to read current state await tester.tap(find.text('Capture')); expect(capturedName, 'Bob'); }); testWidgets('context.state in nested StoreProvider uses correct store', (WidgetTester tester) async { final outerState = AppState(name: 'Outer', counter: 1, flag: false); final innerState = AppState(name: 'Inner', counter: 2, flag: true); final outerStore = Store(initialState: outerState); final innerStore = Store(initialState: innerState); String? outerName; String? innerName; await tester.pumpWidget( MaterialApp( home: StoreProvider( store: outerStore, child: Column( children: [ Builder(builder: (context) { outerName = context.state.name; return Text('Outer: $outerName'); }), StoreProvider( store: innerStore, child: Builder(builder: (context) { innerName = context.state.name; return Text('Inner: $innerName'); }), ), ], ), ), ), ); expect(outerName, 'Outer'); expect(innerName, 'Inner'); }); }); } // Extension for BuildContext extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); } // Test state class class AppState { final String name; final int counter; final bool flag; AppState({ required this.name, required this.counter, required this.flag, }); AppState copyWith({ String? name, int? counter, bool? flag, }) { return AppState( name: name ?? this.name, counter: counter ?? this.counter, flag: flag ?? this.flag, ); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is AppState && other.name == name && other.counter == counter && other.flag == flag; } @override int get hashCode => Object.hash(name, counter, flag); } // Test actions class ChangeNameAction extends ReduxAction { final String name; ChangeNameAction(this.name); @override AppState reduce() { return state.copyWith(name: name); } } class IncrementCounterAction extends ReduxAction { @override AppState reduce() { return state.copyWith(counter: state.counter + 1); } } class ToggleFlagAction extends ReduxAction { @override AppState reduce() { return state.copyWith(flag: !state.flag); } } // Widget to test initState behavior class InitStateTestWidget extends StatefulWidget { final void Function(BuildContext context) onInitState; const InitStateTestWidget({ Key? key, required this.onInitState, }) : super(key: key); @override State createState() => _InitStateTestWidgetState(); } class _InitStateTestWidgetState extends State { @override void initState() { super.initState(); widget.onInitState(context); } @override Widget build(BuildContext context) { return const Text('InitState Test'); } } ================================================ FILE: test/debounce_mixin_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Debounce actions'); Bdd(feature) .scenario( 'Sync action is debounced when dispatched multiple times quickly') .given('A sync action with the Debounce mixin') .when( 'The action is dispatched multiple times within the debounce period') .then('It should only execute once after the debounce period') .run((_) async { var store = Store(initialState: AppState(0)); store.dispatch(DebounceAction()); store.dispatch(DebounceAction()); store.dispatch(DebounceAction()); expect(store.state.count, 0); // Wait for a bit more than the debounce period (150 ms). await Future.delayed(const Duration(milliseconds: 150)); expect(store.state.count, 1); }); Bdd(feature) .scenario( 'Async action is debounced when dispatched multiple times quickly') .given('An async action with the Debounce mixin') .when( 'The action is dispatched multiple times within the debounce period') .then('It should only execute once after the debounce period') .run((_) async { var store = Store(initialState: AppState(0)); store.dispatch(DebounceActionAsync()); store.dispatch(DebounceActionAsync()); store.dispatch(DebounceActionAsync()); expect(store.state.count, 0); // Wait for a bit more than the debounce period (150 ms). await Future.delayed(const Duration(milliseconds: 150)); expect(store.state.count, 1); }); Bdd(feature) .scenario('A sync action executes again after debounce period expires') .given('A sync action with the Debounce mixin') .when('The action is dispatched, ' 'then after waiting for the debounce period, dispatched again') .then('Each dispatch should execute after the debounce period') .run((_) async { var store = Store(initialState: AppState(0)); store.dispatch(DebounceAction()); expect(store.state.count, 0); // Wait for a bit more than the debounce period (150 ms). await Future.delayed(const Duration(milliseconds: 150)); expect(store.state.count, 1); store.dispatch(DebounceAction()); expect(store.state.count, 1); // Wait for a bit more than the debounce period (150 ms). await Future.delayed(const Duration(milliseconds: 150)); expect(store.state.count, 2); }); Bdd(feature) .scenario('An async action executes again after debounce period expires') .given('An async action with the Debounce mixin') .when('The action is dispatched, ' 'then after waiting for the debounce period, dispatched again') .then('Each dispatch should execute after the debounce period') .run((_) async { var store = Store(initialState: AppState(0)); store.dispatch(DebounceActionAsync()); expect(store.state.count, 0); // Wait for a bit more than the debounce period (150 ms). await Future.delayed(const Duration(milliseconds: 150)); expect(store.state.count, 1); store.dispatch(DebounceActionAsync()); expect(store.state.count, 1); // Wait for a bit more than the debounce period (150 ms). await Future.delayed(const Duration(milliseconds: 150)); expect(store.state.count, 2); }); Bdd(feature) .scenario( 'Sync actions with different runtime types are not debounced together') .given( 'Two sync actions with the Debounce mixin but different runtime types') .when('Both actions are dispatched in quick succession') .then( 'Each action should execute independently after their debounce periods') .run((_) async { var store = Store(initialState: AppState(0)); store.dispatch(DebounceActionA()); store.dispatch(DebounceActionB()); expect(store.state.count, 0); // The debounce period is 200ms. // Wait for a bit more than that, but less than double that: 300ms. await Future.delayed(const Duration(milliseconds: 300)); expect(store.state.count, 2); }); Bdd(feature) .scenario( 'Async actions with different runtime types are not debounced together') .given( 'Two async actions with the Debounce mixin but different runtime types') .when('Both actions are dispatched in quick succession') .then( 'Each action should execute independently after their debounce periods') .run((_) async { var store = Store(initialState: AppState(0)); store.dispatch(DebounceActionAAsync()); store.dispatch(DebounceActionBAsync()); expect(store.state.count, 0); // The debounce period is 200ms. // Wait for a bit more than that, but less than double that: 300ms. await Future.delayed(const Duration(milliseconds: 300)); expect(store.state.count, 2); }); } // A simple state that holds a count. class AppState { final int count; AppState(this.count); AppState copy({int? count}) => AppState(count ?? this.count); @override String toString() => 'AppState($count)'; } // An action that uses the Debounce mixin to increment the state. class DebounceAction extends ReduxAction with Debounce { @override int debounce = 100; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Two actions that override lockBuilder to return the same lock. class DebounceAction1 extends ReduxAction with Debounce { @override int debounce = 100; @override AppState reduce() { return state.copy(count: state.count + 1); } } class DebounceAction2 extends ReduxAction with Debounce { @override int debounce = 100; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Two actions with default lock (their runtime types differ). class DebounceActionA extends ReduxAction with Debounce { @override int debounce = 200; @override AppState reduce() { return state.copy(count: state.count + 1); } } class DebounceActionB extends ReduxAction with Debounce { @override int debounce = 200; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Async versions: // An action that uses the Debounce mixin to increment the state. class DebounceActionAsync extends ReduxAction with Debounce { @override int debounce = 100; @override Future reduce() async { await microtask; return state.copy(count: state.count + 1); } } // Two actions that override lockBuilder to return the same lock. class DebounceAction1Async extends ReduxAction with Debounce { @override int debounce = 100; @override Future reduce() async { await microtask; return state.copy(count: state.count + 1); } } class DebounceAction2Async extends ReduxAction with Debounce { @override int debounce = 100; @override Future reduce() async { await microtask; return state.copy(count: state.count + 1); } } // Two actions with default lock (their runtime types differ). class DebounceActionAAsync extends ReduxAction with Debounce { @override int debounce = 200; @override Future reduce() async { await microtask; return state.copy(count: state.count + 1); } } class DebounceActionBAsync extends ReduxAction with Debounce { @override int debounce = 200; @override Future reduce() async { await microtask; return state.copy(count: state.count + 1); } } ================================================ FILE: test/dispatch_and_wait_all_actions_test.dart ================================================ // File: test/dispatch_and_wait_all_actions_test.dart import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { // test('Completes for a sync action', () async { final store = Store(initialState: State(1)); final status = await store.dispatchAndWaitAllActions(IncrementSync()); expect(status.isCompletedOk, true); expect(store.state.count, 2); }); test('Completes for an async action', () async { final store = Store(initialState: State(1)); final status = await store.dispatchAndWaitAllActions(IncrementAsync()); expect(status.isCompletedOk, true); expect(store.state.count, 2); }); test('Waits for nested async dispatch in reduce', () async { var store = Store(initialState: State(1)); var status = await store.dispatchAndWaitAllActions(DispatchMultipleActions()); expect(status.isCompletedOk, true); // 1 → DispatchMultipleActions.reduce → 2 → then IncrementAsync → 3 expect(store.state.count, 3); // --- // Compare it to a normal dispatchAndWait: store = Store(initialState: State(1)); status = await store.dispatchAndWait(DispatchMultipleActions()); expect(status.isCompletedOk, true); // 1 → DispatchMultipleActions.reduce → 2 → then IncrementAsync → 3 expect(store.state.count, 2); }); } class State { final int count; State(this.count); } class IncrementSync extends ReduxAction { @override State reduce() => State(state.count + 1); } class IncrementAsync extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 10)); return State(state.count + 1); } } class DispatchMultipleActions extends ReduxAction { @override Future reduce() async { // First, update the state synchronously. final updated = State(state.count + 1); // Then dispatch another async action. dispatch(IncrementAsync()); return updated; } } ================================================ FILE: test/dispatch_and_wait_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Dispatch and wait'); Bdd(feature) .scenario('Waiting for a dispatchAndWait to end.') .given('A SYNC or ASYNC action.') .when('The action is dispatched with `dispatchAndWait(action)`.') .then('It returns a `Promise` that resolves when the action finishes.') .run((_) async { final store = Store(initialState: State(1)); expect(store.state.count, 1); await store.dispatch(IncrementSync()); expect(store.state.count, 2); await store.dispatch(IncrementAsync()); expect(store.state.count, 3); }); Bdd(feature) .scenario('Knowing when some action dispatched with `dispatchAndWait` is being processed.') .given('A SYNC or ASYNC action.') .when('The action is dispatched.') .then('We can check if the action is processing with `Store.isWaiting(actionType)`.') .run((_) async { final store = Store(initialState: State(1)); // SYNC ACTION: isWaiting is always false. expect(store.isWaiting(IncrementSync), false); expect(store.state.count, 1); var actionSync = IncrementSync(); expect(actionSync.status.isDispatched, false); var promise1 = store.dispatch(actionSync); expect(actionSync.status.isDispatched, true); expect(store.isWaiting(IncrementSync), false); expect(store.state.count, 2); await promise1; // Since it's SYNC, it's already finished when dispatched. expect(store.isWaiting(IncrementSync), false); expect(store.state.count, 2); // ASYNC ACTION: isWaiting is true while we wait for it to finish. expect(store.isWaiting(IncrementAsync), false); expect(store.state.count, 2); var actionAsync = IncrementAsync(); expect(actionAsync.status.isDispatched, false); var promise2 = store.dispatch(actionAsync); expect(actionAsync.status.isDispatched, true); expect(store.isWaiting(IncrementAsync), true); // True! expect(store.state.count, 2); await promise2; // Since it's ASYNC, it really waits until it finishes. expect(store.isWaiting(IncrementAsync), false); expect(store.state.count, 3); }); Bdd(feature) .scenario('Reading the ActionStatus of the action.') .given('A SYNC or ASYNC action.') .when('The action is dispatched.') .and('The action finishes without any errors.') .then('We can check the action status, which says the action completed OK (no errors).') .run((_) async { final store = Store(initialState: State(1)); // SYNC ACTION var actionSync = IncrementSync(); var status = actionSync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, false); status = await store.dispatchAndWait(actionSync); expect(status, actionSync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, true); expect(status.hasFinishedMethodReduce, true); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, true); expect(status.isCompletedFailed, false); // ASYNC ACTION var actionAsync = IncrementAsync(); status = actionAsync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, false); status = await store.dispatchAndWait(actionAsync); expect(status, actionAsync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, true); expect(status.hasFinishedMethodReduce, true); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, true); expect(status.isCompletedFailed, false); }); Bdd(feature) .scenario('Reading the ActionStatus of the action.') .given('A SYNC or ASYNC action.') .when('The action is dispatched.') .and('The action fails in the "before" method.') .then('We can check the action status, which says the action completed with errors.') .run((_) async { final store = Store(initialState: State(1)); // SYNC ACTION var actionSync = IncrementSyncBeforeFails(); var status = actionSync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); status = await store.dispatchAndWait(actionSync); expect(status, actionSync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, true); // ASYNC ACTION var actionAsync = IncrementAsyncBeforeFails(); status = actionAsync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); status = await store.dispatchAndWait(actionAsync); expect(status, actionAsync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, true); }); Bdd(feature) .scenario('Reading the ActionStatus of the action.') .given('A SYNC or ASYNC action.') .when('The action is dispatched.') .and('The action fails in the "reduce" method.') .then('We can check the action status, which says the action completed with errors.') .run((_) async { final store = Store(initialState: State(1)); // SYNC ACTION var actionSync = IncrementSyncReduceFails(); var status = actionSync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); status = await store.dispatchAndWait(actionSync); expect(status, actionSync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, true); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, true); // ASYNC ACTION var actionAsync = IncrementAsyncReduceFails(); status = actionAsync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); status = await store.dispatchAndWait(actionAsync); expect(status, actionAsync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, true); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, true); }); Bdd(feature) .scenario('Reading the ActionStatus of the action.') .given('A SYNC or ASYNC action.') .when('The action is dispatched.') .and('The action fails in the "reduce" method.') .then('We can check the action status, which says the action completed with errors.') .run((_) async { final store = Store(initialState: State(1)); // SYNC ACTION var actionSync = IncrementSyncReduceFails(); var status = actionSync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); status = await store.dispatchAndWait(actionSync); expect(status, actionSync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, true); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, true); // ASYNC ACTION var actionAsync = IncrementAsyncReduceFails(); status = actionAsync.status; expect(status.isDispatched, false); expect(status.hasFinishedMethodBefore, false); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, false); expect(status.isCompleted, false); status = await store.dispatchAndWait(actionAsync); expect(status, actionAsync.status); expect(status.isDispatched, true); expect(status.hasFinishedMethodBefore, true); expect(status.hasFinishedMethodReduce, false); expect(status.hasFinishedMethodAfter, true); // After is like a "finally" block. It always runs. expect(status.isCompleted, true); expect(status.isCompletedOk, false); expect(status.isCompletedFailed, true); }); } class State { final int count; State(this.count); } class IncrementSync extends ReduxAction { @override State reduce() { return State(state.count + 1); } } class IncrementAsync extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 50)); return State(state.count + 1); } } class IncrementSyncBeforeFails extends ReduxAction { @override void before() { throw const UserException('Before failed'); } @override State reduce() { return State(state.count + 1); } } class IncrementSyncReduceFails extends ReduxAction { @override State reduce() { throw const UserException('Reduce failed'); } } class IncrementSyncAfterFails extends ReduxAction { @override State reduce() { return State(state.count + 1); } @override void after() { throw const UserException('After failed'); } } class IncrementAsyncBeforeFails extends ReduxAction { @override Future before() async { throw const UserException('Before failed'); } @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 50)); return State(state.count + 1); } } class IncrementAsyncReduceFails extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 50)); throw const UserException('Reduce failed'); } } class IncrementAsyncAfterFails extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 50)); return State(state.count + 1); } @override Future after() async { throw const UserException('After failed'); } } ================================================ FILE: test/dispatch_sync_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('DispatchSync'); Bdd(feature) .scenario('DispatchSync only dispatches SYNC actions.') .given('A SYNC or ASYNC action.') .when('The action is dispatched with `dispatchSync(action)`.') .then('It throws a `StoreException` when the action is ASYNC.') .and('It fails synchronously.') .note('We have to separately test with async "before", async "reduce", ' 'and both "before" and "reduce" being async, because they fail in different ways.') .run((_) async { var store = Store(initialState: State(1)); // Works store.dispatchSync(IncrementSync()); // Fails synchronously with a `StoreException`. expect(() => store.dispatchSync(IncrementAsyncBefore()), throwsA(isA())); expect(() => store.dispatchSync(IncrementAsyncReduce()), throwsA(isA())); expect(() => store.dispatchSync(IncrementAsyncBeforeReduce()), throwsA(isA())); }); } class State { final int count; State(this.count); } class IncrementSync extends ReduxAction { @override State reduce() => State(state.count + 1); } class IncrementAsyncBefore extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 10)); } @override State reduce() => State(state.count + 1); } class IncrementAsyncReduce extends ReduxAction { @override Future reduce() async { return State(state.count + 1); } } class IncrementAsyncBeforeReduce extends ReduxAction { @override Future before() async { await Future.delayed(const Duration(milliseconds: 10)); } @override Future reduce() async { return State(state.count + 1); } } ================================================ FILE: test/dispatch_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Dispatch'); Bdd(feature) .scenario('Waiting for a dispatch to end.') .given('A SYNC or ASYNC action.') .when('The action is dispatched with `dispatch(action)`.') .then('The SYNC action changes the state synchronously.') .and('The ASYNC action changes the state asynchronously.') .run((_) async { final store = Store(initialState: State(1)); // The SYNC action changes the state synchronously. expect(store.state.count, 1); store.dispatch(IncrementSync()); expect(store.state.count, 2); // The ASYNC action does NOT change the state synchronously. store.dispatch(IncrementAsync()); expect(store.state.count, 2); // But the ASYNC action changes the state asynchronously. await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.count, 3); }); Bdd(feature) .scenario('Knowing when some action dispatched with `dispatch` is being processed.') .given('A SYNC or ASYNC action.') .when('The action is dispatched.') .then('We can check if the action is processing with `Store.isWaiting(action)`.') .run((_) async { final store = Store(initialState: State(1)); // SYNC ACTION: isWaiting is always false. expect(store.isWaiting(IncrementSync), false); expect(store.state.count, 1); var actionSync = IncrementSync(); store.dispatch(actionSync); expect(store.isWaiting(IncrementSync), false); expect(store.state.count, 2); // ASYNC ACTION: isWaiting is true while we wait for it to finish. expect(store.isWaiting(IncrementAsync), false); expect(store.state.count, 2); var actionAsync = IncrementAsync(); store.dispatch(actionAsync); expect(store.isWaiting(IncrementAsync), true); // True! expect(store.state.count, 2); // Since it's ASYNC, it really waits until it finishes. await Future.delayed(const Duration(milliseconds: 50)); expect(store.isWaiting(IncrementAsync), false); expect(store.state.count, 3); }); } class State { final int count; State(this.count); } class IncrementSync extends ReduxAction { @override State reduce() { return State(state.count + 1); } } class IncrementAsync extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 1)); return State(state.count + 1); } } ================================================ FILE: test/event_redux_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('Typedef Evt.', () { expect(Event.spent(), Evt.spent()); expect((Event).toString(), 'Event'); expect((Evt).toString(), 'Event'); }); test('Boolean event equals.', () { // Spent events are always equal. expect(Event.spent(), Event.spent()); expect(Event.spent(), Evt.spent()); // Not-spent events are always different. expect(Event(), isNot(Event())); // An event not-spent is always different from a spent event. expect(Event.spent(), isNot(Event())); }); test('String event equals.', () { // Spent events are always equal. expect(Event.spent(), Event.spent()); // Not-spent events are always different. expect(Event('String'), isNot(Event('String'))); // An event not-spent is always different from a spent event. expect(Event.spent(), isNot(Event())); }); test('Number event equals.', () { // Spent events are always equal. expect(Event.spent(), Event.spent()); // Not-spent events are always different. expect(Event(123), isNot(Event(123))); // An event not-spent is always different from a spent event. expect(Event.spent(), isNot(Event())); }); test('EventMultiple', () { Event evt1 = Event("Mary"); Event evt2 = Event("Anna"); EventMultiple evt = EventMultiple(evt1, evt2); expect(evt.isSpent, false); expect(evt.isNotSpent, true); expect(evt.state, "Mary"); expect(evt.state, "Mary"); expect(evt.isSpent, false); expect(evt.isNotSpent, true); expect(evt.consume(), "Mary"); expect(evt.state, "Anna"); expect(evt.isSpent, false); expect(evt.isNotSpent, true); expect(evt.consume(), "Anna"); expect(evt.state, null); expect(evt.isSpent, true); expect(evt.isNotSpent, false); expect(evt.consume(), null); expect(evt.isSpent, true); expect(evt.isNotSpent, false); }); test('MappedEvent', () { List users = ["Mary", "Anna", "Arnold", "Jake", "Frank", "Suzy"]; String? Function(int?) mapFunction = (index) => index == null ? null : users[index]; Event userEvt1 = Event.map(Event(3), mapFunction); Event userEvt2 = MappedEvent(Event(2), mapFunction); // Consume the event. expect(userEvt1.consume(), "Jake"); expect(userEvt1.consume(), null); expect(userEvt1.isSpent, true); expect(userEvt1.isNotSpent, false); // Don't consume the event. expect(userEvt2.state, "Arnold"); expect(userEvt2.state, "Arnold"); expect(userEvt2.isSpent, false); expect(userEvt2.isNotSpent, true); // A spent event is different from a not-spent one. expect(userEvt1 == userEvt2, isFalse); // Now consume the second event. expect(userEvt2.consume(), "Arnold"); expect(userEvt2.isSpent, true); expect(userEvt2.isNotSpent, false); // A spent event is equal to a spent one. expect(userEvt1 == userEvt2, isTrue); }); test('Typedef EvtState.', () { expect((EvtState).toString(), 'EvtState'); expect((EvtState()).runtimeType.toString(), 'EvtState'); }); test('Boolean event equals.', () { expect(EvtState() == EvtState(), isFalse); expect(EvtState('abc') == EvtState('abc'), isFalse); expect(EvtState('abc') == EvtState('123'), isFalse); expect(EvtState().hashCode == EvtState().hashCode, isFalse); expect(EvtState('abc').hashCode == EvtState('abc').hashCode, isFalse); expect(EvtState('abc').hashCode == EvtState('123').hashCode, isFalse); var x = EvtState(); var y = x; expect(x == y, isTrue); expect(x.hashCode == y.hashCode, isTrue); }); test('Getting the value. It is not consumed.', () { expect(EvtState().value, isNull); expect(EvtState('abc').value, 'abc'); expect(EvtState('abc').value, 'abc'); expect(EvtState(123).value, 123); expect(EvtState(123).value, 123); }); } ================================================ FILE: test/failed_action_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Failed action'); Bdd(feature) .scenario('Checking if a SYNC action has failed.') .given('A SYNC action.') .when('The action is dispatched twice with `dispatch(action)`.') .and('The action fails the first time, but not the second time.') .then('We can check that the action failed the first time, but not the second.') .and('We can get the action exception the first time, but null the second time.') .and('We can clear the failing flag.') .run((_) async { final store = Store(initialState: State(1)); // When the SYNC action fails, the failed flag is set. expect(store.isFailed(SyncActionThatFails), false); var actionFail = SyncActionThatFails(true); store.dispatch(actionFail); expect(store.isFailed(SyncActionThatFails), true); expect(store.exceptionFor(SyncActionThatFails), const UserException('Yes, it failed.')); // When the same action is dispatched and does not fail, the failed flag is cleared. var actionSuccess = SyncActionThatFails(false); store.dispatch(actionSuccess); expect(store.isFailed(SyncActionThatFails), false); expect(store.exceptionFor(SyncActionThatFails), null); // Test clearing the exception. // Fail it again. store.dispatch(SyncActionThatFails(true)); expect(store.isFailed(SyncActionThatFails), true); expect(store.exceptionFor(SyncActionThatFails), const UserException('Yes, it failed.')); // We clear the exception for ANOTHER action. It doesn't clear anything. store.clearExceptionFor(AsyncActionThatFails); expect(store.isFailed(SyncActionThatFails), true); expect(store.exceptionFor(SyncActionThatFails), const UserException('Yes, it failed.')); // We clear the exception for the correct action. Now it's NOT failing anymore. store.clearExceptionFor(SyncActionThatFails); expect(store.isFailed(SyncActionThatFails), false); expect(store.exceptionFor(SyncActionThatFails), null); }); Bdd(feature) .scenario('Checking if an ASYNC action has failed.') .given('An ASYNC action.') .when('The action is dispatched twice with `dispatch(action)`.') .and('The action fails the first time, but not the second time.') .then('We can check that the action failed the first time, but not the second.') .and('We can get the action exception the first time, but null the second time.') .and('We can clear the failing flag.') .run((_) async { final store = Store(initialState: State(1)); // Initially, flag tells us it's NOT failing. expect(store.isFailed(AsyncActionThatFails), false); var actionFail = AsyncActionThatFails(true); // The action is dispatched, but it's ASYNC. We wait for it. await store.dispatch(actionFail); // Now it's failed. expect(store.isFailed(AsyncActionThatFails), true); expect(store.exceptionFor(AsyncActionThatFails), const UserException('Yes, it failed.')); // We clear the exception, so that it's NOT failing. store.clearExceptionFor(AsyncActionThatFails); expect(store.isFailed(AsyncActionThatFails), false); actionFail = AsyncActionThatFails(true); // The action is dispatched, but it's ASYNC. store.dispatch(actionFail); // So, there was no time to fail. expect(store.isFailed(AsyncActionThatFails), false); // We wait until it really finishes. await Future.delayed(const Duration(milliseconds: 50)); // Now it's failed. expect(store.isFailed(AsyncActionThatFails), true); expect(store.exceptionFor(AsyncActionThatFails), const UserException('Yes, it failed.')); // We dispatch the same action type again. actionFail = AsyncActionThatFails(true); store.dispatch(actionFail); // This act of dispatching it cleared the flag. expect(store.isFailed(AsyncActionThatFails), false); // We wait until it really finishes, again. await Future.delayed(const Duration(milliseconds: 500)); // Not it's failed, again. expect(store.isFailed(AsyncActionThatFails), true); expect(store.exceptionFor(AsyncActionThatFails), const UserException('Yes, it failed.')); }); } class State { final int count; State(this.count); } class SyncActionThatFails extends ReduxAction { final bool ifFails; SyncActionThatFails(this.ifFails); @override State? reduce() { if (ifFails) throw const UserException('Yes, it failed.'); return null; } } class AsyncActionThatFails extends ReduxAction { final bool ifFails; AsyncActionThatFails(this.ifFails); @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 1)); if (ifFails) throw const UserException('Yes, it failed.'); return null; } } ================================================ FILE: test/fresh_mixin_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Fresh mixin'); // ========================================================================== // Case 1: Initial no key, ignoreFresh == false, success // ========================================================================== Bdd(feature) .scenario('Action succeeds when no fresh key exists') .given('No fresh key exists for the action') .when('The action is dispatched') .then('It should execute and create a fresh key') .run((_) async { var store = Store(initialState: AppState(0)); // First dispatch - should run since no key exists await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); // Dispatch again immediately - should abort (key is fresh) await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); }); // ========================================================================== // Case 2: Initial no key, ignoreFresh == false, error // ========================================================================== Bdd(feature) .scenario('Action fails when no fresh key exists - key should be removed') .given('No fresh key exists for the action') .when('The action is dispatched and fails') .then('It should execute, then remove the fresh key on error') .run((_) async { var store = Store(initialState: AppState(0)); // First dispatch - should run and fail, key should be removed await store.dispatchAndWait( FreshAction( shouldFail: true, // True! ignoreFresh: false, ), ); // The action failed, so the state should not have changed expect(store.state.count, 0); // Dispatch again - should run because the key was removed after error // This time it succeeds, proving it actually ran await store.dispatch( FreshAction( shouldFail: false, // False! ignoreFresh: false, ), ); expect(store.state.count, 1); // Proves the second dispatch ran }); // ========================================================================== // Case 3: Initial stale key, ignoreFresh == false, success // ========================================================================== Bdd(feature) .scenario('Action succeeds when a stale key exists') .given('A stale fresh key exists for the action') .when('The action is dispatched') .then('It should execute and update the fresh key') .run((_) async { var store = Store(initialState: AppState(0)); // First dispatch with short freshFor await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); // Wait for the fresh period to expire (150ms freshFor + buffer) await Future.delayed(const Duration(milliseconds: 200)); // Now the key is stale, so it should run again await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 2); }); // ========================================================================== // Case 4: Initial stale key, ignoreFresh == false, error // ========================================================================== Bdd(feature) .scenario('Action fails when a stale key exists - restores stale state') .given('A stale fresh key exists for the action') .when('The action is dispatched and fails') .then('It should restore the old stale expiry') .run((_) async { var store = Store(initialState: AppState(0)); // Create a stale key by dispatching and waiting await store.dispatch(FreshAction( shouldFail: false, ignoreFresh: false, )); expect(store.state.count, 1); // Wait for the key to become stale await Future.delayed(const Duration(milliseconds: 200)); // Now dispatch with failure - it should run (stale), fail, and restore stale state await store.dispatchAndWait(FreshAction( shouldFail: true, // Fail! ignoreFresh: false, )); expect(store.state.count, 1); // No change due to failure // The key should still be stale, so we can dispatch again await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 2); }); // ========================================================================== // Case 5: Initial fresh key, ignoreFresh == false // ========================================================================== Bdd(feature) .scenario('Action aborts when fresh key exists') .given('A fresh key exists for the action') .when('The action is dispatched again') .then('It should abort without executing') .run((_) async { var store = Store(initialState: AppState(0)); // First dispatch - should run await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); // Dispatch again immediately - should abort (key is still fresh) await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); // Wait for fresh period to expire await Future.delayed( const Duration(milliseconds: 200), ); // Now it should run again await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 2); }); // ========================================================================== // Case 6: ignoreFresh == true, success // ========================================================================== Bdd(feature) .scenario( 'Action with ignoreFresh=true always runs and stays fresh on success') .given('An action with ignoreFresh set to true') .when('The action is dispatched even when fresh') .then('It should execute and create a new fresh period') .run((_) async { var store = Store(initialState: AppState(0)); // First dispatch with ignoreFresh - should run await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: true, ), ); expect(store.state.count, 1); // Dispatch again immediately with ignoreFresh - should run again await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: true, ), ); expect(store.state.count, 2); // After success, the key should be fresh // So a normal action (without ignoreFresh) should be aborted await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 2); // Aborted // Dispatch again immediately with ignoreFresh - should run again await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: true, ), ); expect(store.state.count, 3); }); // ========================================================================== // Case 7: ignoreFresh == true, error // ========================================================================== Bdd(feature) .scenario('Action with ignoreFresh=true runs but removes key on error') .given('An action with ignoreFresh set to true') .when('The action is dispatched and fails') .then('It should execute and remove the key on error') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch with ignoreFresh and failure - should run and then remove key await store.dispatchAndWait( FreshAction( shouldFail: true, // Fail! ignoreFresh: true, // True! ), ); expect(store.state.count, 0); // No change due to failure // The key should be removed, so a normal action should run. await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); }); // ========================================================================== // Case 8: removeKey in reduce or before // ========================================================================== Bdd(feature) .scenario('Action calls removeKey - no rollback on error') .given('An action that calls removeKey in reduce') .when('The action fails') .then('The key should remain removed (no rollback)') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch action that removes key and fails await store.dispatchAndWait( FreshAction( shouldRemoveKey: true, // Remove key! shouldFail: true, // Fail! ignoreFresh: false, ), ); expect(store.state.count, 0); // The key was manually removed, so it should run again await store.dispatch( FreshAction( shouldFail: false, shouldRemoveKey: false, ignoreFresh: false, ), ); expect(store.state.count, 1); }); // ========================================================================== // Case 9: removeKey in reduce or before // ========================================================================== Bdd(feature) .scenario('Action calls removeKey - allows immediate re-dispatch') .given('An action that calls removeKey in reduce') .when('The action succeeds') .then('The key should be removed and action can run again') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch action that removes its own key await store.dispatch( FreshAction( shouldRemoveKey: true, // Remove key! shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); // The key was removed, so it should run again immediately await store.dispatch( FreshAction( shouldFail: false, shouldRemoveKey: false, ignoreFresh: false, ), ); expect(store.state.count, 2); }); // ========================================================================== // Case 10: removeAllKeys in reduce or before // ========================================================================== Bdd(feature) .scenario('Action calls removeAllKeys - clears all fresh keys') .given('An action that calls removeAllKeys in reduce') .when('The action is dispatched') .then('All fresh keys should be removed') .run((_) async { var store = Store(initialState: AppState(0)); // Create fresh keys for different actions await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); await store.dispatch(FreshAction2()); expect(store.state.count, 2); // Both should be fresh, so re-dispatch should abort await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); await store.dispatch(FreshAction2()); expect(store.state.count, 2); // Now dispatch an action that removes all keys // Use ignoreFresh so it runs even though the key is fresh await store.dispatch( FreshAction( shouldRemoveAllKeys: true, // Remove all keys! shouldFail: false, ignoreFresh: true, // Force run even if fresh! ), ); expect(store.state.count, 3); // Now both actions should run again (keys removed) await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); await store.dispatch(FreshAction2()); expect(store.state.count, 5); }); // ========================================================================== // Case 11: Two actions A then B, same key, B dispatched while K is fresh from A // ========================================================================== Bdd(feature) .scenario('Two actions with same key - second aborts when first is fresh') .given('Two actions that share the same fresh key') .when('Both are dispatched while the key is fresh') .then('Only the first action should execute') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch first action with shared key await store.dispatch(FreshActionSharedKey1()); expect(store.state.count, 1); // Dispatch second action with same key - should abort await store.dispatch(FreshActionSharedKey2()); expect(store.state.count, 1); // Wait for fresh period to expire await Future.delayed(const Duration(milliseconds: 1100)); // Now second action should run await store.dispatch(FreshActionSharedKey2()); expect(store.state.count, 2); }); // ======================================================================== // Case 12: Two actions A then B, same key, B dispatched after expiry // ======================================================================== Bdd(feature) .scenario( 'Two actions A then B - B failure does not block future shared-key runs') .given('Action A runs successfully, then B runs and fails for same key') .when('The shared key was already stale when B ran') .then( 'B\'s failure should not stop another shared-key action from running') .run((_) async { var store = Store(initialState: AppState(0)); // Action A runs successfully and sets the shared key as fresh. await store.dispatch(FreshActionSharedKey1()); expect(store.state.count, 1); // Wait for the shared key to become stale (freshFor == 1000ms). await Future.delayed(const Duration(milliseconds: 1100)); // Action B runs and fails. This should not change the state. await store.dispatchAndWait(FreshActionSharedKey2Fails()); expect(store.state.count, 1); // After B's failure, the shared key should be stale again. // So another shared-key action should run and change the state. await store.dispatch(FreshActionSharedKey1()); expect(store.state.count, 2); }); // ========================================================================== // Case 13: Tests different runtime types // ========================================================================== Bdd(feature) .scenario( 'Actions with different runtime types have independent freshness') .given('Two actions with Fresh mixin but different runtime types') .when('Both actions are dispatched in quick succession') .then('Both should execute independently') .run((_) async { var store = Store(initialState: AppState(0)); await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 1); await store.dispatch(FreshAction2()); expect(store.state.count, 2); // Both should be fresh now await store.dispatch( FreshAction( shouldFail: false, ignoreFresh: false, ), ); expect(store.state.count, 2); // Aborted await store.dispatch(FreshAction2()); expect(store.state.count, 2); // Aborted }); // ========================================================================== // Case 14: Test freshKeyParams // ========================================================================== Bdd(feature) .scenario('Actions with freshKeyParams differentiate by parameters') .given('Actions that use freshKeyParams to differentiate instances') .when('Actions with different params are dispatched') .then('They should have independent freshness') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch with param "A" await store.dispatch( FreshActionWithParams('A'), ); expect(store.state.count, 1); // Dispatch with param "B" - different key, should run await store.dispatch( FreshActionWithParams('B'), ); expect(store.state.count, 2); // Dispatch with param "A" again - same key, should abort await store.dispatch( FreshActionWithParams('A'), ); expect(store.state.count, 2); // Dispatch with param "B" again - same key, should abort await store.dispatch( FreshActionWithParams('B'), ); expect(store.state.count, 2); }); // ========================================================================== // Case 15: Test freshKeyParams // ========================================================================== Bdd(feature) .scenario('Actions with freshKeyParams share freshness for same params') .given('Actions with the same freshKeyParams value') .when('Dispatched in quick succession') .then('The second should abort') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch with param "X" await store.dispatch( FreshActionWithParams('X'), ); expect(store.state.count, 1); // Dispatch with same param "X" - should abort await store.dispatch( FreshActionWithParams('X'), ); expect(store.state.count, 1); }); // ========================================================================== // Case 16: Concurrency protection: A fails, B succeeds, no previous key // ========================================================================== Bdd(feature) .scenario( 'Concurrency: A fails after B succeeds - B\'s freshness is preserved') .given('Action A dispatched first but takes time to complete') .and('Action B dispatched after A\'s freshness expires, succeeds quickly') .when('A finishes and fails after B has already succeeded') .then('A\'s failure should NOT remove the fresh key set by B') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch action A that: // - Uses a shared key // - Has a short freshFor (100ms) so it expires quickly // - Takes a long time to execute (300ms delay) // - Fails at the end // Don't await - let it run in background store.dispatch( FreshActionConcurrentSlow( shouldFail: true, delayMillis: 300, freshForMillis: 100, ), ); // Wait for A's freshness to expire (100ms + buffer) await Future.delayed(const Duration(milliseconds: 150)); // At this point: // - A is still running (only 150ms passed, A needs 300ms) // - A's freshness has expired (100ms freshFor) // - Map[K] = expiryA (stale) // Dispatch action B that: // - Uses the same shared key // - Executes quickly // - Succeeds await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); // B has succeeded and set Map[K] = expiryB (fresh) // Count should be 1 from B's success (A hasn't finished yet) expect(store.state.count, 1); // Wait for A to finish (it takes 300ms total, we've waited 150ms + dispatch time) // So wait another 200ms to be safe await Future.delayed(const Duration(milliseconds: 200)); // A has now failed // The critical assertion: A's failure should NOT have removed the key // Because A's after() sees current = expiryB != _newExpiryA // So the rollback is skipped // Dispatch another action with the same key // If B's freshness is preserved, this should abort await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); // Count should still be 1 (second dispatch was aborted because key is fresh from B) expect(store.state.count, 1); // Wait for B's freshness to expire await Future.delayed(const Duration(milliseconds: 1100)); // Now the key should be stale, so dispatch should succeed await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); expect(store.state.count, 2); }); // ========================================================================== // Case 17: Concurrency protection: Previous stale expiry exists, A fails, B succeeds // ========================================================================== Bdd(feature) .scenario( 'Concurrency: Previous stale expiry, A fails after B succeeds - B\'s freshness is preserved') .given('An initial action creates a stale expiry for the key') .and('A slow failing action A starts and updates the expiry') .and('A fast succeeding action B runs after A\'s freshness expires') .when('A later fails after B has already succeeded') .then( 'A\'s failure should NOT restore the old stale expiry or remove B\'s freshness') .run((_) async { final store = Store(initialState: AppState(0)); // Step 1: Initial action C writes the first expiry and succeeds. // This gives us a "previous expiry" (_current != null for the next action). await store.dispatch( FreshActionConcurrentSlow( shouldFail: false, delayMillis: 0, freshForMillis: 100, // short fresh period ), ); // C succeeded once expect(store.state.count, 1); // Wait for C's freshness to expire so its expiry is stale but still in the map. await Future.delayed(const Duration(milliseconds: 150)); // Step 2: Dispatch A (slow, failing) with the same key and same freshFor. // At this point: // - Map['concurrentKey'] = prevExpiry (from C), which is stale. // - A.abortDispatch sees _current != null and writes a new expiryA. store.dispatch( FreshActionConcurrentSlow( shouldFail: true, delayMillis: 300, // long running, will fail later freshForMillis: 100, ), ); // Wait long enough for A's fresh window (100ms) to expire, // but not long enough for A to finish (300ms total). await Future.delayed(const Duration(milliseconds: 150)); // Step 3: Dispatch B (fast, succeeding) with the same key. // Now: // - Map['concurrentKey'] = expiryA (from A), which is stale at this point. // - B.abortDispatch sees stale expiry and writes expiryB. await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); // C and B have succeeded, A is still running and will fail later. // Count: 1 (C) + 1 (B) = 2. expect(store.state.count, 2); // Step 4: Wait for A to finish and fail. await Future.delayed(const Duration(milliseconds: 200)); // At this time: // - Map['concurrentKey'] == expiryB (written by B). // - A.after sees status.originalError != null, // current = expiryB, _newExpiryA = expiryA, _currentA = prevExpiry. // - Since current != _newExpiryA, rollback is skipped. // So A must NOT restore prevExpiry or remove the key. // Step 5: Dispatch B again while its freshFor (1000ms) has not expired. // If B's freshness is preserved, this dispatch should abort // and the reducer should NOT run. await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); expect(store.state.count, 2); // Optional: Wait for B's freshness to expire and confirm that the key // becomes stale and a new dispatch can run. await Future.delayed(const Duration(milliseconds: 1100)); await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); expect(store.state.count, 3); }); // ========================================================================== // Case 18: Concurrency + ignoreFresh: A fails after B succeeds - B's freshness preserved // ========================================================================== Bdd(feature) .scenario( 'Concurrency with ignoreFresh: A fails after B succeeds - B\'s freshness is preserved') .given( 'A slow action A with ignoreFresh=true starts and reserves freshness') .and('After A\'s freshFor expires, a fast action B runs and succeeds') .when('A later fails after B has already succeeded') .then('A\'s failure should NOT remove or revert B\'s freshness') .run((_) async { final store = Store(initialState: AppState(0)); // Step 1: Dispatch A (slow, failing, ignoreFresh=true). // // A characteristics: // - freshFor = 100ms // - ignoreFresh = true // - delay = 300ms, then fails // // At A.abortDispatch (t0): // - Map['concurrentKey'] is probably null (no previous key) // - ignoreFresh branch: // Map['concurrentKey'] = expiryA = t0 + 100ms // _newExpiryA = expiryA // _currentA = null // // Do NOT await: let A run in background. store.dispatch( FreshActionConcurrentSlowIgnoreFresh( shouldFail: true, delayMillis: 300, freshForMillis: 100, ), ); // Step 2: Wait until A's fresh window expires, but A is still running. // // After 150ms: // - expiryA (t0 + 100ms) is in the past => stale // - A is still running (needs 300ms) await Future.delayed(const Duration(milliseconds: 150)); // Step 3: Dispatch B (fast, succeeds) with the same key. // // At B.abortDispatch (t1 ~ t0 + 150ms): // - Map['concurrentKey'] = expiryA (stale) // - B sees stale, writes: // expiryB = t1 + 1000ms // Map['concurrentKey'] = expiryB // _newExpiryB = expiryB // // Then B.reduce succeeds and increments count. await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); // So far: // - Only B has succeeded => count = 1 // - Map['concurrentKey'] = expiryB (fresh) expect(store.state.count, 1); // Step 4: Wait for A to finish and fail. // // After another 200ms: // - Total since A started ~350ms > 300ms => A finishes and fails. // // In A.after: // - status.originalError != null // - current = Map['concurrentKey'] = expiryB // - _newExpiryA = expiryA // - current != _newExpiryA => rollback block is SKIPPED // So A does NOT remove the key (even though _currentA == null). await Future.delayed(const Duration(milliseconds: 200)); // Step 5: Dispatch B again while expiryB is still in the future. // // If B's freshness was preserved, this dispatch must abort // (abortDispatch returns true) and NOT increment the counter. await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); // Still only one successful run from B. expect(store.state.count, 1); // Optional: Wait for B's freshness to expire, then B should run again. await Future.delayed(const Duration(milliseconds: 1100)); await store.dispatch( FreshActionConcurrentFast( shouldFail: false, ), ); expect(store.state.count, 2); }); // ========================================================================== // Case 19: Scenario 4: Nested override between abort and after, failing outer action // ========================================================================== Bdd(feature) .scenario( 'Nested override: failing outer action must not revert newer freshness') .given('OuterActionNested reserves freshness for a key') .and('OverrideAction runs inside its reduce and sets a newer freshness') .when('OuterActionNested later fails and after() runs') .then('The newer freshness from OverrideAction must be preserved') .run((_) async { final store = Store(initialState: AppState(0)); // Step 1: Dispatch the outer action that will: // - In abortDispatch: reserve expiryA for "nestedKey". // - In reduce: dispatch OverrideAction, which: // * runs (ignoreFresh = true), // * sets a newer expiryB for "nestedKey", // * increments count to 1. // - Then OuterActionNested throws. try { await store.dispatch(OuterActionNested()); fail('Expected OuterActionNested to throw'); } catch (_) { // Expected failure from OuterActionNested. } // At this point: // - OverrideAction has succeeded exactly once => count should be 1. expect(store.state.count, 1); // In after() of OuterActionNested: // - status.originalError != null // - current = Map['nestedKey'] is the expiry set by OverrideAction // - _newExpiry (from OuterActionNested) is the earlier expiryA // - current != _newExpiry => rollback is skipped // // So the key must still be fresh according to OverrideAction. // Step 2: Dispatch CheckAction with the same key. // // If the newer freshness from OverrideAction is preserved: // - abortDispatch of CheckAction sees a fresh key and returns true // - reduce() is NOT called and count stays 1. // // If OuterActionNested had reverted or removed the key on error: // - the key would be stale // - CheckAction would run and increment count to 2. await store.dispatch(CheckAction()); // Assert that CheckAction was aborted (did not increment the count). expect(store.state.count, 1); }); // --------------------------------------------------------------------------- // ========================================================================== // Case 20: Fresh mixin cannot be combined with Throttle // ========================================================================== Bdd(feature) .scenario('Fresh mixin cannot be combined with Throttle') .given('An action that combines Fresh and Throttle mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(FreshWithThrottleAction()), throwsA(isA().having( (e) => e.message, 'message', 'The Fresh mixin cannot be combined with the Throttle mixin.', )), ); }); // ========================================================================== // Case 21: Fresh mixin cannot be combined with NonReentrant // ========================================================================== Bdd(feature) .scenario('Fresh mixin cannot be combined with NonReentrant') .given('An action that combines Fresh and NonReentrant mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(FreshWithNonReentrantAction()), throwsA(isA().having( (e) => e.message, 'message', 'The Fresh mixin cannot be combined with the NonReentrant mixin.', )), ); }); // ========================================================================== // Case 22: Fresh mixin cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'Fresh mixin cannot be combined with UnlimitedRetryCheckInternet') .given( 'An action that combines Fresh and UnlimitedRetryCheckInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(FreshWithUnlimitedRetryAction()), throwsA(isA().having( (e) => e.message, 'message', 'The Fresh mixin cannot be combined with the UnlimitedRetryCheckInternet mixin.', )), ); }); // ========================================================================== // Case 23: Throttle mixin cannot be combined with NonReentrant // ========================================================================== Bdd(feature) .scenario('Throttle mixin cannot be combined with NonReentrant') .given('An action that combines Throttle and NonReentrant mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); // NonReentrant.abortDispatch() runs and detects Throttle expect( () => store.dispatch(ThrottleWithNonReentrantAction()), throwsA(isA().having( (e) => e.message, 'message', 'The NonReentrant mixin cannot be combined with the Throttle mixin.', )), ); }); // ========================================================================== // Case 24: Throttle mixin cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'Throttle mixin cannot be combined with UnlimitedRetryCheckInternet') .given( 'An action that combines Throttle and UnlimitedRetryCheckInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); // UnlimitedRetryCheckInternet.abortDispatch() runs and detects Throttle expect( () => store.dispatch(ThrottleWithUnlimitedRetryAction()), throwsA(isA().having( (e) => e.message, 'message', 'The UnlimitedRetryCheckInternet mixin cannot be combined with the Throttle mixin.', )), ); }); // ========================================================================== // Case 25: NonReentrant mixin cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'NonReentrant mixin cannot be combined with UnlimitedRetryCheckInternet') .given( 'An action that combines NonReentrant and UnlimitedRetryCheckInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); // UnlimitedRetryCheckInternet.abortDispatch() runs and detects NonReentrant expect( () => store.dispatch(NonReentrantWithUnlimitedRetryAction()), throwsA(isA().having( (e) => e.message, 'message', 'The UnlimitedRetryCheckInternet mixin cannot be combined with the NonReentrant mixin.', )), ); }); // ========================================================================== // Case 26: CheckInternet mixin cannot be combined with AbortWhenNoInternet // ========================================================================== Bdd(feature) .scenario( 'CheckInternet mixin cannot be combined with AbortWhenNoInternet') .given( 'An action that combines CheckInternet and AbortWhenNoInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); // AbortWhenNoInternet.before() runs and detects CheckInternet expect( () => store.dispatch(CheckInternetWithAbortWhenNoInternetAction()), throwsA(isA().having( (e) => e.message, 'message', 'The AbortWhenNoInternet mixin cannot be combined with the CheckInternet mixin.', )), ); }); // ========================================================================== // Case 27: CheckInternet mixin cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'CheckInternet mixin cannot be combined with UnlimitedRetryCheckInternet') .given( 'An action that combines CheckInternet and UnlimitedRetryCheckInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); // UnlimitedRetryCheckInternet.abortDispatch() runs first and detects CheckInternet expect( () => store.dispatch(CheckInternetWithUnlimitedRetryAction()), throwsA(isA().having( (e) => e.message, 'message', 'The UnlimitedRetryCheckInternet mixin cannot be combined with the CheckInternet mixin.', )), ); }); // ========================================================================== // Case 28: AbortWhenNoInternet mixin cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'AbortWhenNoInternet mixin cannot be combined with UnlimitedRetryCheckInternet') .given( 'An action that combines AbortWhenNoInternet and UnlimitedRetryCheckInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); // UnlimitedRetryCheckInternet.abortDispatch() runs first and detects AbortWhenNoInternet expect( () => store.dispatch(AbortWhenNoInternetWithUnlimitedRetryAction()), throwsA(isA().having( (e) => e.message, 'message', 'The UnlimitedRetryCheckInternet mixin cannot be combined with the AbortWhenNoInternet mixin.', )), ); }); // --------------------------------------------------------------------------- // ========================================================================== // Case 29: Rapid consecutive dispatches get different tokens (regression test) // ========================================================================== Bdd(feature) .scenario('Rapid consecutive dispatches get different tokens') .given('Two actions dispatched in rapid succession (same millisecond)') .when('The first action fails after the second succeeds') .then('The second action freshness should be preserved') .note('This is a regression test for the DateTime equality bug') .run((_) async { var store = Store(initialState: AppState(0)); // Dispatch a slow action that will fail var slowFuture = store.dispatchAndWait( RapidTestActionSlow(shouldFail: true), ); // Immediately dispatch another action with ignoreFresh // This happens within the same millisecond await store.dispatch(RapidTestActionFast()); expect(store.state.count, 1); // Wait for the slow action to complete (and fail) await slowFuture; // The fast action's freshness should be preserved // If it was incorrectly reverted, this action would run await store.dispatch(RapidTestActionCheck()); expect(store.state.count, 1); // Should still be 1 (check was aborted) }); // ========================================================================== // Case 30: Triple nesting - innermost ignoreFresh failure removes key // ========================================================================== Bdd(feature) .scenario('Triple nesting: A->B->C where A and C fail, B succeeds') .given('Action A dispatches B, B dispatches C') .and('B succeeds, but A and C both fail') .when('All actions complete') .then('Key is removed because C (last ignoreFresh writer) failed') .note('C overwrote B entry, then C failed and removed key') .run((_) async { var store = Store(initialState: AppState(0)); try { await store.dispatch(TripleNestOuterAction( middleShouldFail: false, innerShouldFail: true, outerShouldFail: true, )); fail('Expected outer action to throw'); } catch (_) {} // B succeeded and incremented count expect(store.state.count, 1); // C (ignoreFresh) failed and removed the key, so check action runs await store.dispatch(TripleNestCheckAction()); expect(store.state.count, 2); // Runs because C's failure removed key }); // ========================================================================== // Case 31: Triple nesting - middle fails, outer and inner succeed // ========================================================================== Bdd(feature) .scenario('Triple nesting: A->B->C where B fails, A and C succeed') .given('Action A dispatches B, B dispatches C') .and('C succeeds, B fails after dispatching C, A succeeds after B') .when('All actions complete') .then('C freshness should be preserved') .run((_) async { var store = Store(initialState: AppState(0)); // Note: A catches B's failure internally and succeeds await store.dispatch(TripleNestOuterAction( middleShouldFail: true, innerShouldFail: false, outerShouldFail: false, )); // C succeeded and incremented count, A succeeded and incremented count // B failed so it didn't increment expect(store.state.count, 2); // Check if freshness is preserved from C await store.dispatch(TripleNestCheckAction()); expect(store.state.count, 2); // Should still be 2 }); // ========================================================================== // Case 32: Restore of stale entry still results in stale state // ========================================================================== Bdd(feature) .scenario('Restored stale freshness allows subsequent actions') .given('Action A sets freshness with short duration') .and('Action B runs after A expires and sets new freshness') .and('B fails and restores A previous (now stale) freshness') .when('Action C is dispatched') .then('C should run because restored freshness is stale') .note('When B restores A expired entry, C still runs since its stale') .run((_) async { var store = Store(initialState: AppState(0)); // A runs and sets freshness with short duration (50ms) await store.dispatch(RestoreTestActionA()); expect(store.state.count, 1); // Wait for A's freshness to expire await Future.delayed(const Duration(milliseconds: 60)); // B runs (A's freshness expired), sets new freshness, then fails await store.dispatchAndWait(RestoreTestActionB()); // B failed, so count stays at 1 expect(store.state.count, 1); // B restored A's old entry, but that entry is stale (expired) // C should run because the restored freshness is already expired await store.dispatch(RestoreTestActionC()); expect(store.state.count, 2); // C runs because restored entry is stale }); // ========================================================================== // Case 33: Sequential failures - each removes key, allowing next to run // ========================================================================== Bdd(feature) .scenario('Sequential failures each remove key') .given('Action A fails (removes key)') .and('Action B runs and fails (removes key)') .and('Action C runs and fails (removes key)') .when('Action D is dispatched') .then('D should run because key was removed') .run((_) async { var store = Store(initialState: AppState(0)); // A fails await store.dispatchAndWait(SequentialFailAction(id: 'A')); expect(store.state.count, 0); // B runs (key was removed) and fails await store.dispatchAndWait(SequentialFailAction(id: 'B')); expect(store.state.count, 0); // C runs (key was removed) and fails await store.dispatchAndWait(SequentialFailAction(id: 'C')); expect(store.state.count, 0); // D runs (key was removed) and succeeds await store.dispatch(SequentialSucceedAction()); expect(store.state.count, 1); }); // ========================================================================== // Case 34: Nested ignoreFresh failure removes key even if outer succeeds // ========================================================================== Bdd(feature) .scenario('Nested ignoreFresh failure removes freshness key') .given('Action A (normal) dispatches B (ignoreFresh=true)') .and('B fails and removes its key entry') .when('A succeeds') .then('Key should be removed because ignoreFresh failure makes it stale') .note('ignoreFresh with _current=null removes key on failure by design') .run((_) async { var store = Store(initialState: AppState(0)); // A dispatches B inside, B fails (removes key), A succeeds await store.dispatch(OuterNormalInnerIgnoreFreshAction()); expect(store.state.count, 1); // Only A incremented // Check freshness - B's failure removed the key, so this should run await store.dispatch(OuterNormalCheckAction()); expect(store.state.count, 2); // Runs because key was removed by B's failure }); // ========================================================================== // Case 35: Concurrent ignoreFresh - last writer's failure removes key // ========================================================================== Bdd(feature) .scenario('Concurrent ignoreFresh actions - last writer determines outcome') .given('Three ignoreFresh actions start concurrently') .and('First fails, second succeeds, third fails') .when('All complete') .then('Key is removed because last abortDispatch writer failed') .note('With concurrent ignoreFresh, the last to call abortDispatch owns ' 'the entry. If that action fails, the key is removed.') .run((_) async { var store = Store(initialState: AppState(0)); // Start three concurrent actions // All call abortDispatch() at roughly the same time // The last one to write to the map "owns" the entry var future1 = store.dispatchAndWait(ConcurrentIgnoreFreshAction( id: 1, delayMs: 100, shouldFail: true, )); var future2 = store.dispatchAndWait(ConcurrentIgnoreFreshAction( id: 2, delayMs: 50, shouldFail: false, )); var future3 = store.dispatchAndWait(ConcurrentIgnoreFreshAction( id: 3, delayMs: 150, shouldFail: true, )); await Future.wait([future1, future2, future3]); // Only action 2 succeeded expect(store.state.count, 1); // Action 3 was likely the last to write (or one of the failing actions was) // When the last writer fails, it removes the key because ignoreFresh // sets _current = null, so failure removes the entry await store.dispatch(ConcurrentIgnoreFreshCheck()); expect(store.state.count, 2); // Runs because key was removed }); // ========================================================================== // Case 36: Aborted action should not affect freshness on failure path // ========================================================================== Bdd(feature) .scenario('Aborted action does not interfere with freshness') .given('Action A sets freshness') .and('Action B is aborted (key is fresh)') .when('A check action runs') .then('Freshness from A should still be valid') .run((_) async { var store = Store(initialState: AppState(0)); // A runs and sets freshness await store.dispatch(AbortTestActionA()); expect(store.state.count, 1); // B is aborted (doesn't run) await store.dispatch(AbortTestActionB()); expect(store.state.count, 1); // Still 1 // Freshness should still be valid await store.dispatch(AbortTestActionCheck()); expect(store.state.count, 1); // Should abort }); // --------------------------------------------------------------------------- } // ============================================================================= // Test state and actions // ============================================================================= class AppState { final int count; AppState(this.count); AppState copy({int? count}) => AppState(count ?? this.count); @override String toString() => 'AppState($count)'; } // Action with ignoreFresh class FreshAction extends ReduxAction with Fresh { final bool shouldFail; final bool shouldRemoveKey; final bool shouldRemoveAllKeys; final bool _ignoreFresh; final int _freshFor; FreshAction({ required this.shouldFail, required bool ignoreFresh, this.shouldRemoveKey = false, this.shouldRemoveAllKeys = false, int freshFor = 150, }) : _ignoreFresh = ignoreFresh, _freshFor = freshFor; @override int get freshFor => _freshFor; @override bool get ignoreFresh => _ignoreFresh; @override AppState reduce() { if (shouldFail) { throw const UserException('Intentional failure'); } if (shouldRemoveKey) { removeKey(); } if (shouldRemoveAllKeys) { removeAllKeys(); } return state.copy(count: state.count + 1); } } // Second action type for testing different runtime types class FreshAction2 extends ReduxAction with Fresh { @override int get freshFor => 1000; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Actions with shared key class FreshActionSharedKey1 extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'sharedKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } class FreshActionSharedKey2 extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'sharedKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } class FreshActionSharedKey2Fails extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'sharedKey'; @override AppState reduce() { throw const UserException('Intentional failure'); } } // Action with freshKeyParams class FreshActionWithParams extends ReduxAction with Fresh { final String param; FreshActionWithParams(this.param); @override int get freshFor => 1000; @override Object? freshKeyParams() => param; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Actions for concurrency test - slow action that takes time to complete class FreshActionConcurrentSlow extends ReduxAction with Fresh { final bool shouldFail; final int delayMillis; final int freshForMillis; FreshActionConcurrentSlow({ required this.shouldFail, required this.delayMillis, required this.freshForMillis, }); @override int get freshFor => freshForMillis; @override Object computeFreshKey() => 'concurrentKey'; @override Future reduce() async { // Simulate long-running operation await Future.delayed(Duration(milliseconds: delayMillis)); if (shouldFail) { throw const UserException('Intentional failure from slow action'); } return state.copy(count: state.count + 1); } } // Fast action for concurrency test class FreshActionConcurrentFast extends ReduxAction with Fresh { final bool shouldFail; FreshActionConcurrentFast({required this.shouldFail}); @override int get freshFor => 1000; @override Object computeFreshKey() => 'concurrentKey'; @override AppState reduce() { if (shouldFail) { throw const UserException('Intentional failure from fast action'); } return state.copy(count: state.count + 1); } } // Slow action with ignoreFresh=true for concurrency tests class FreshActionConcurrentSlowIgnoreFresh extends ReduxAction with Fresh { final bool shouldFail; final int delayMillis; final int freshForMillis; FreshActionConcurrentSlowIgnoreFresh({ required this.shouldFail, required this.delayMillis, required this.freshForMillis, }); @override int get freshFor => freshForMillis; @override bool get ignoreFresh => true; @override Object computeFreshKey() => 'concurrentKey'; @override Future reduce() async { // Simulate long-running operation await Future.delayed(Duration(milliseconds: delayMillis)); if (shouldFail) { throw const UserException( 'Intentional failure from slow ignoreFresh action'); } return state.copy(count: state.count + 1); } } // Outer action: reserves freshness, then dispatches OverrideAction, then fails. class OuterActionNested extends ReduxAction with Fresh { @override int get freshFor => 1000; // Long enough so it won't expire during the test. @override Object computeFreshKey() => 'nestedKey'; @override bool get ignoreFresh => false; @override Future reduce() async { // "External" writer: overrides the freshness while this action is running. await dispatch(OverrideAction()); // Now fail, after OverrideAction has already succeeded. throw Exception('OuterActionNested fails after override'); } } // Override action: always runs, sets freshness, increments count. class OverrideAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'nestedKey'; @override bool get ignoreFresh => true; // Always run and reset freshness. @override AppState reduce() { // This is our "external" writer that sets the final freshness. return state.copy(count: state.count + 1); } } // Check action: normal Fresh semantics, used to verify freshness state. class CheckAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'nestedKey'; @override bool get ignoreFresh => false; @override AppState reduce() { // Should only run if the key is stale. return state.copy(count: state.count + 1); } } // Action that combines Fresh with Throttle (incompatible) class FreshWithThrottleAction extends ReduxAction with Throttle, // ignore: private_collision_in_mixin_application Fresh { @override int get freshFor => 1000; @override int get throttle => 1000; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines Fresh with NonReentrant (incompatible) class FreshWithNonReentrantAction extends ReduxAction with NonReentrant, // ignore: private_collision_in_mixin_application Fresh { @override int get freshFor => 1000; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines Fresh with UnlimitedRetryCheckInternet (incompatible) class FreshWithUnlimitedRetryAction extends ReduxAction with UnlimitedRetryCheckInternet, // ignore: private_collision_in_mixin_application Fresh { @override int get freshFor => 1000; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines Throttle with NonReentrant (incompatible) class ThrottleWithNonReentrantAction extends ReduxAction with Throttle, // ignore: private_collision_in_mixin_application NonReentrant { @override int get throttle => 1000; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines Throttle with UnlimitedRetryCheckInternet (incompatible) class ThrottleWithUnlimitedRetryAction extends ReduxAction with Throttle, // ignore: private_collision_in_mixin_application UnlimitedRetryCheckInternet { @override int get throttle => 1000; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines NonReentrant with UnlimitedRetryCheckInternet (incompatible) class NonReentrantWithUnlimitedRetryAction extends ReduxAction with NonReentrant, // ignore: private_collision_in_mixin_application UnlimitedRetryCheckInternet { @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines CheckInternet with AbortWhenNoInternet (incompatible) class CheckInternetWithAbortWhenNoInternetAction extends ReduxAction with CheckInternet, // ignore: private_collision_in_mixin_application AbortWhenNoInternet { @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines CheckInternet with UnlimitedRetryCheckInternet (incompatible) class CheckInternetWithUnlimitedRetryAction extends ReduxAction with CheckInternet, // ignore: private_collision_in_mixin_application UnlimitedRetryCheckInternet { @override AppState reduce() { return state.copy(count: state.count + 1); } } // Action that combines AbortWhenNoInternet with UnlimitedRetryCheckInternet (incompatible) class AbortWhenNoInternetWithUnlimitedRetryAction extends ReduxAction with AbortWhenNoInternet, // ignore: private_collision_in_mixin_application UnlimitedRetryCheckInternet { @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Case 29: Rapid consecutive dispatches (regression test) // ============================================================================= class RapidTestActionSlow extends ReduxAction with Fresh { final bool shouldFail; RapidTestActionSlow({required this.shouldFail}); @override int get freshFor => 1000; @override Object computeFreshKey() => 'rapidKey'; @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 10)); if (shouldFail) { throw const UserException('Slow action fails'); } return state.copy(count: state.count + 1); } } class RapidTestActionFast extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'rapidKey'; @override bool get ignoreFresh => true; @override AppState reduce() { return state.copy(count: state.count + 1); } } class RapidTestActionCheck extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'rapidKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Cases 30-31: Triple nesting tests // ============================================================================= class TripleNestOuterAction extends ReduxAction with Fresh { final bool middleShouldFail; final bool innerShouldFail; final bool outerShouldFail; TripleNestOuterAction({ required this.middleShouldFail, required this.innerShouldFail, required this.outerShouldFail, }); @override int get freshFor => 1000; @override Object computeFreshKey() => 'tripleNestKey'; @override Future reduce() async { try { await dispatch(TripleNestMiddleAction( shouldFail: middleShouldFail, innerShouldFail: innerShouldFail, )); } catch (_) { // Middle failed, but outer continues } if (outerShouldFail) { throw const UserException('Outer fails'); } return state.copy(count: state.count + 1); } } class TripleNestMiddleAction extends ReduxAction with Fresh { final bool shouldFail; final bool innerShouldFail; TripleNestMiddleAction({ required this.shouldFail, required this.innerShouldFail, }); @override int get freshFor => 1000; @override Object computeFreshKey() => 'tripleNestKey'; @override bool get ignoreFresh => true; @override Future reduce() async { try { await dispatch(TripleNestInnerAction(shouldFail: innerShouldFail)); } catch (_) { // Inner failed, but middle continues } if (shouldFail) { throw const UserException('Middle fails'); } return state.copy(count: state.count + 1); } } class TripleNestInnerAction extends ReduxAction with Fresh { final bool shouldFail; TripleNestInnerAction({required this.shouldFail}); @override int get freshFor => 1000; @override Object computeFreshKey() => 'tripleNestKey'; @override bool get ignoreFresh => true; @override AppState reduce() { if (shouldFail) { throw const UserException('Inner fails'); } return state.copy(count: state.count + 1); } } class TripleNestCheckAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'tripleNestKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Case 32: Restore preserves freshness // ============================================================================= class RestoreTestActionA extends ReduxAction with Fresh { @override int get freshFor => 50; // Short, so B can run after it expires @override Object computeFreshKey() => 'restoreKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } class RestoreTestActionB extends ReduxAction with Fresh { @override int get freshFor => 500; // Longer fresh period @override Object computeFreshKey() => 'restoreKey'; @override AppState reduce() { // This will set new freshness, then fail, which should restore A's throw const UserException('B fails intentionally'); } } class RestoreTestActionC extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'restoreKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Case 33: Sequential failures // ============================================================================= class SequentialFailAction extends ReduxAction with Fresh { final String id; SequentialFailAction({required this.id}); @override int get freshFor => 1000; @override Object computeFreshKey() => 'sequentialKey'; @override AppState reduce() { throw UserException('Action $id fails'); } } class SequentialSucceedAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'sequentialKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Case 34: Nested ignoreFresh failure // ============================================================================= class OuterNormalInnerIgnoreFreshAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'outerNormalKey'; @override Future reduce() async { try { await dispatch(InnerIgnoreFreshFailAction()); } catch (_) { // Inner failed, outer continues } return state.copy(count: state.count + 1); } } class InnerIgnoreFreshFailAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'outerNormalKey'; @override bool get ignoreFresh => true; @override AppState reduce() { throw const UserException('Inner ignoreFresh fails'); } } class OuterNormalCheckAction extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'outerNormalKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Case 35: Multiple concurrent ignoreFresh // ============================================================================= class ConcurrentIgnoreFreshAction extends ReduxAction with Fresh { final int id; final int delayMs; final bool shouldFail; ConcurrentIgnoreFreshAction({ required this.id, required this.delayMs, required this.shouldFail, }); @override int get freshFor => 1000; @override Object computeFreshKey() => 'concurrentIgnoreFreshKey'; @override bool get ignoreFresh => true; @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMs)); if (shouldFail) { throw UserException('Action $id fails'); } return state.copy(count: state.count + 1); } } class ConcurrentIgnoreFreshCheck extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'concurrentIgnoreFreshKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // ============================================================================= // Actions for Case 36: Aborted action doesn't interfere // ============================================================================= class AbortTestActionA extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'abortTestKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } class AbortTestActionB extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'abortTestKey'; // Will be aborted because A's key is fresh @override AppState reduce() { return state.copy(count: state.count + 1); } } class AbortTestActionCheck extends ReduxAction with Fresh { @override int get freshFor => 1000; @override Object computeFreshKey() => 'abortTestKey'; @override AppState reduce() { return state.copy(count: state.count + 1); } } ================================================ FILE: test/local_json_persist_test.dart ================================================ // Please run this test file by itself, not together with other tests. import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:async_redux/async_redux.dart'; import 'package:async_redux/local_json_persist.dart'; import 'package:async_redux/src/local_persist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'test_utils.dart'; enum files { abcd, xyzk } void main() { WidgetsFlutterBinding.ensureInitialized(); group('Do not run on CI', skip: isCI, () { test('Encode and decode state.', () async { // List simpleObj = [ 'Hello', 'How are you?', [ 1, 2, 3, {'name': 'John'} ], 42, true, false ]; Uint8List encoded = LocalJsonPersist.encodeJson(simpleObj); Object? decoded = LocalJsonPersist.decodeJson(encoded); expect(decoded, simpleObj); expect( (decoded as List) .map((obj) => "$obj (${obj.runtimeType})") .join("\n"), 'Hello (String)\n' 'How are you? (String)\n' '[1, 2, 3, {name: John}] (List)\n' '42 (int)\n' 'true (bool)\n' 'false (bool)'); }); test('Save and load state.', () async { // // Use a random number to make sure it's not checking already saved files. int randNumber = Random().nextInt(100000); List simpleObj = [ 'Goodbye', '"Life is what happens\n\rwhen you\'re busy making other plans." -John Lennon', [ 100, 200, {"name": "João"} ], true, randNumber, ]; var persist = LocalJsonPersist("abcd"); await persist.save(simpleObj); Object? decoded = await persist.load(); expect(decoded, simpleObj); expect( (decoded as List) .map((obj) => "$obj (${obj.runtimeType})") .join("\n"), 'Goodbye (String)\n' '"Life is what happens\n\rwhen you\'re busy making other plans." -John Lennon (String)\n' '[100, 200, {name: João}] (List)\n' 'true (bool)\n' '$randNumber (int)'); // Cleans up test. await persist.delete(); }); test('Test file can be defined by String or enum.', () async { // File file = await (LocalJsonPersist("abcd").file()); expect( file.path.endsWith("\\db\\abcd.json") || file.path.endsWith("/db/abcd.json"), isTrue); file = await (LocalJsonPersist(files.abcd).file()); expect( file.path.endsWith("\\db\\abcd.json") || file.path.endsWith("/db/abcd.json"), isTrue); file = await (LocalJsonPersist(files.xyzk, dbSubDir: "kkk").file()); expect( file.path.endsWith("\\kkk\\xyzk.json") || file.path.endsWith("/kkk/xyzk.json"), isTrue); }); test('Test dbDir and subDirs.', () async { // File file = await (LocalJsonPersist("xyzk").file()); expect( file.path.endsWith("\\xyzk.json") || file.path.endsWith("/xyzk.json"), isTrue); file = await (LocalJsonPersist("xyzk", dbSubDir: "kkk").file()); expect( file.path.endsWith("\\kkk\\xyzk.json") || file.path.endsWith("/kkk/xyzk.json"), isTrue); file = await (LocalJsonPersist("xyzk", dbSubDir: "kkk", subDirs: ["mno"]) .file()); expect( file.path.endsWith("\\kkk\\mno\\xyzk.json") || file.path.endsWith("/kkk/mno/xyzk.json"), isTrue); file = await (LocalJsonPersist("xyzk", dbSubDir: "kkk", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\kkk\\m\\n\\o\\xyzk.json") || file.path.endsWith("/kkk/m/n/o/xyzk.json"), isTrue); file = await (LocalJsonPersist("xyzk", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\db\\mno\\xyzk.json") || file.path.endsWith("/db/mno/xyzk.json"), isTrue); file = await (LocalJsonPersist("xyzk", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\db\\m\\n\\o\\xyzk.json") || file.path.endsWith("/db/m/n/o/xyzk.json"), isTrue); String saveDefaultDbSubDir = LocalJsonPersist.defaultDbSubDir; LocalJsonPersist.defaultDbSubDir = "myDir"; file = await (LocalJsonPersist("xyzk", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\myDir\\mno\\xyzk.json") || file.path.endsWith("/myDir/mno/xyzk.json"), isTrue); file = await (LocalJsonPersist("xyzk", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\myDir\\m\\n\\o\\xyzk.json") || file.path.endsWith("/myDir/m/n/o/xyzk.json"), isTrue); LocalJsonPersist.defaultDbSubDir = ""; file = await (LocalJsonPersist("xyzk", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\mno\\xyzk.json") || file.path.endsWith("/mno/xyzk.json"), isTrue); expect( file.path.endsWith("\\db\\mno\\xyzk.json") || file.path.endsWith("/db/mno/xyzk.json"), isFalse); print('file.path = ${file.path}'); file = await (LocalJsonPersist("xyzk", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\m\\n\\o\\xyzk.json") || file.path.endsWith("/m/n/o/xyzk.json"), isTrue); expect( file.path.endsWith("\\db\\m\\n\\o\\xyzk.json") || file.path.endsWith("/db/m/n/o/xyzk.json"), isFalse); LocalJsonPersist.defaultDbSubDir = ""; file = await (LocalJsonPersist("xyzk", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\mno\\xyzk.json") || file.path.endsWith("/mno/xyzk.json"), isTrue); expect( file.path.endsWith("\\db\\mno\\xyzk.json") || file.path.endsWith("/db/mno/xyzk.json"), isFalse); print('file.path = ${file.path}'); file = await (LocalJsonPersist("xyzk", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\m\\n\\o\\xyzk.json") || file.path.endsWith("/m/n/o/xyzk.json"), isTrue); expect( file.path.endsWith("\\db\\m\\n\\o\\xyzk.json") || file.path.endsWith("/db/m/n/o/xyzk.json"), isFalse); LocalJsonPersist.defaultDbSubDir = saveDefaultDbSubDir; }); test('Add objects to save, and load from file name.', () async { // // User random numbers to make sure it's not checking already saved files. var rand = Random(); int randNumber1 = rand.nextInt(1000); int randNumber2 = rand.nextInt(1000); int randNumber3 = rand.nextInt(1000); var persist = LocalJsonPersist("xyzk"); await persist.save([randNumber1, randNumber2, randNumber3]); Object? decoded = await persist.load(); expect(decoded, [randNumber1, randNumber2, randNumber3]); expect( (decoded as List) .map((obj) => "$obj (${obj.runtimeType})") .join("\n"), '$randNumber1 (int)\n' '$randNumber2 (int)\n' '$randNumber3 (int)'); // Cleans up test. await persist.delete(); }); test('Test create, overwrite and delete the file.', () async { // var persist = LocalJsonPersist("klm"); // Create. await persist.save([123]); var decoded = await persist.load(); expect(decoded, [123]); // Overwrite. await persist.save([789]); decoded = await persist.load(); expect(decoded, [789]); // Delete. File file = await (persist.file()); expect(file.existsSync(), true); await persist.delete(); expect(file.existsSync(), false); }); test("Load/Length/Exists file that doesn't exist, or exists and is empty.", () async { // // File doesn't exist. var persist = LocalJsonPersist("doesNotExist"); expect(await persist.load(), isNull); expect(await persist.length(), 0); expect(await persist.exists(), false); // File exists and is empty. persist = LocalJsonPersist("my_file"); await persist.save([]); expect(await persist.load(), []); expect(await persist.length(), 2); expect(await persist.exists(), true); // File exists and contains Json null, which is 4 chars: n, u, l and l. persist = LocalJsonPersist("my_file"); await persist.save(null); expect(await persist.load(), null); expect(await persist.length(), 4); expect(await persist.exists(), true); }); test("Deletes a file that exists or doesn't exist.", () async { // // File doesn't exist. var persist = LocalJsonPersist("doesNotExist"); expect(await persist.delete(), isFalse); // File exists and is deleted. persist = LocalJsonPersist("my_file"); await persist.save([]); expect(await persist.delete(), isTrue); }); test('Load as object.', () async { // // Use a random number to make sure it's not checking already saved files. int randNumber = Random().nextInt(100000); Map simpleObj = { "one": 1, "two": randNumber, }; var persist = LocalJsonPersist("obj"); await persist.save(simpleObj); Map? decoded = await persist.loadAsObj(); expect(decoded, simpleObj); // Cleans up test. await persist.delete(); }); test('Loading an object-which-is-not-a-map as single object, fails.', () async { // List simpleObj = [ { "one": 1, "two": 2, }, { "three": 1, "four": 2, } ]; var persist = LocalJsonPersist("obj"); await persist.save(simpleObj); dynamic error; try { await persist.loadAsObj(); } catch (_error) { error = _error; } expect( error, PersistException( "Not an object: [{one: 1, two: 2}, {three: 1, four: 2}]")); // Cleans up test. await persist.delete(); }); test('Load as object (map) something which is not an object.', () async { // List simpleObj = ["hey"]; var persist = LocalJsonPersist("obj"); await persist.save(simpleObj); dynamic error; try { await persist.loadAsObj(); } catch (_error) { error = _error; } expect(error, PersistException("Not an object: [hey]")); // Cleans up test. await persist.delete(); }); test('Encode and decode as JSON.', () async { // List simpleObj = [ 'Hello', 'How are you?', [ 1, 2, 3, {'name': 'John'} ], 42, true, false ]; Uint8List encoded = LocalJsonPersist.encodeJson(simpleObj); Object? decoded = LocalJsonPersist.decodeJson(encoded); expect(decoded, simpleObj); expect( (decoded as List) .map((obj) => "$obj (${obj.runtimeType})") .join("\n"), 'Hello (String)\n' 'How are you? (String)\n' '[1, 2, 3, {name: John}] (List)\n' '42 (int)\n' 'true (bool)\n' 'false (bool)'); }); test('Save and load a single string into/from JSON.', () async { // Object simpleObj = 'Goodbye'; var persist = LocalJsonPersist("abcd"); await persist.save(simpleObj); Object? decoded = await persist.load(); expect(decoded, simpleObj); expect(decoded, 'Goodbye'); // Cleans up test. await persist.delete(); }); test('loadConverting from .json file', () async { var simpleObj = {'Hello': 123}; var persist = LocalJsonPersist("abcd"); await persist.save(simpleObj); Object? decoded = await persist.loadConverting(isList: false); expect(decoded, simpleObj); expect(await persist.exists(), isTrue); expect((await persist.file()).toString(), endsWith('\\db\\abcd.json\'')); // Cleans up test. await persist.delete(); }); test('loadConverting from .db (json-sequence) file', () async { // var simpleObj = {'Hello': 123}; // Save inside a List. var listWithOneElement = [simpleObj]; var persistSequence = LocalPersist("abcd"); await persistSequence.save(listWithOneElement); // The '.db' file exists. expect(await persistSequence.exists(), isTrue); expect((await persistSequence.file()).toString(), endsWith('\\db\\abcd.db\'')); // --- // The '.json' file does NOT exist. var persist = LocalJsonPersist("abcd"); expect(await persist.exists(), isFalse); // When we load converting... Object? decoded = await persist.loadConverting(isList: false); expect(decoded, simpleObj); // The '.json' file now exists. expect(await persist.exists(), isTrue); expect((await persist.file()).toString(), endsWith('\\db\\abcd.json\'')); // But the '.db' file was deleted. expect(await persistSequence.exists(), isFalse); // --- // We now can read the '.json' file again. persist = LocalJsonPersist("abcd"); expect(await persist.exists(), isTrue); // And it works just the same. decoded = await persist.loadConverting(isList: false); expect(decoded, simpleObj); // --- // Cleans up test. await persist.delete(); }); test( 'loadConverting from .db (json-sequence) file fails for more than 1 object', () async { var simpleObj = ['Hello', 123]; var persistSequence = LocalPersist("abcd"); await persistSequence.save(simpleObj); dynamic _error; var persist = LocalJsonPersist("abcd"); try { await persist.loadConverting(isList: false); } catch (error) { _error = error; expect(error is PersistException, isTrue); expect(error.toString(), 'Json sequence to Json: 2 objects: [Hello, 123].'); } expect(_error, isNot(null)); // Cleans up test. await persist.delete(); }); test('loadAsObjConverting from .db (json-sequence) file', () async { // var simpleObj = {'Hello': 123}; // Save inside a List. var listWithOneElement = [simpleObj]; var persistSequence = LocalPersist("abcd"); await persistSequence.save(listWithOneElement); // The '.db' file exists. expect(await persistSequence.exists(), isTrue); expect((await persistSequence.file()).toString(), endsWith('\\db\\abcd.db\'')); // --- // The '.json' file does NOT exist. var persist = LocalJsonPersist("abcd"); expect(await persist.exists(), isFalse); // When we load converting... Map? decoded = await persist.loadAsObjConverting(); expect(decoded, simpleObj); // The '.json' file now exists. expect(await persist.exists(), isTrue); expect((await persist.file()).toString(), endsWith('\\db\\abcd.json\'')); // But the '.db' file was deleted. expect(await persistSequence.exists(), isFalse); // --- // We now can read the '.json' file again. persist = LocalJsonPersist("abcd"); expect(await persist.exists(), isTrue); // And it works just the same. decoded = await persist.loadAsObjConverting(); expect(decoded, simpleObj); // --- // Cleans up test. await persist.delete(); }); test('loadConverting from .json file', () async { var simpleObj = {'Hello': 123}; var persist = LocalJsonPersist("abcd"); await persist.save(simpleObj); Object? decoded = await persist.loadConverting(isList: false); expect(decoded, simpleObj); expect(await persist.exists(), isTrue); expect((await persist.file()).toString(), endsWith('\\db\\abcd.json\'')); // Cleans up test. await persist.delete(); }); test('loadConverting from .db (json-sequence) file', () async { // var simpleObj = {'Hello': 123}; // Save inside a List. var listWithOneElement = [simpleObj]; var persistSequence = LocalPersist("abcd"); await persistSequence.save(listWithOneElement); // The '.db' file exists. expect(await persistSequence.exists(), isTrue); expect((await persistSequence.file()).toString(), endsWith('\\db\\abcd.db\'')); // --- // The '.json' file does NOT exist. var persist = LocalJsonPersist("abcd"); expect(await persist.exists(), isFalse); // When we load converting... Object? decoded = await persist.loadConverting(isList: true); expect(decoded, [simpleObj]); // The '.json' file now exists. expect(await persist.exists(), isTrue); expect((await persist.file()).toString(), endsWith('\\db\\abcd.json\'')); // But the '.db' file was deleted. expect(await persistSequence.exists(), isFalse); // --- // We now can read the '.json' file again. persist = LocalJsonPersist("abcd"); expect(await persist.exists(), isTrue); // And it works just the same. decoded = await persist.loadConverting(isList: true); expect(decoded, [simpleObj]); // --- // Cleans up test. await persist.delete(); }); test('loadConverting from .db (json-sequence) file for a single object', () async { // var simpleObj = ['Hello']; var persistSequence = LocalPersist("abcd"); await persistSequence.save(simpleObj); var persist = LocalJsonPersist("abcd"); var decoded = await persist.loadConverting(isList: true); expect(decoded, simpleObj); // --- var persistJson = LocalJsonPersist(simpleObj); await persistJson.save(simpleObj); decoded = await persistJson.load(); expect(decoded, simpleObj); decoded = await persistJson.loadConverting(isList: true); expect(decoded, simpleObj); // --- // Cleans up test. await persist.delete(); }); test('loadConverting from .db (json-sequence) file for more than 1 object', () async { // var simpleObj = ['Hello', 123]; var persistSequence = LocalPersist("abcd"); await persistSequence.save(simpleObj); var persist = LocalJsonPersist("abcd"); var decoded = await persist.loadConverting(isList: true); expect(decoded, simpleObj); // --- var persistJson = LocalJsonPersist(simpleObj); await persistJson.save(simpleObj); decoded = await persistJson.load(); expect(decoded, simpleObj); decoded = await persistJson.loadConverting(isList: true); expect(decoded, simpleObj); // --- // Cleans up test. await persist.delete(); }); test( 'loadConverting from .db (json-sequence) file for a list inside a list', () async { // var simpleObj = [ ['Hello', 123] ]; var persistSequence = LocalPersist("abcd"); await persistSequence.save(simpleObj); var persist = LocalJsonPersist("abcd"); var decoded = await persist.loadConverting(isList: true); expect(decoded, simpleObj); // --- var persistJson = LocalJsonPersist(simpleObj); await persistJson.save(simpleObj); decoded = await persistJson.load(); expect(decoded, simpleObj); decoded = await persistJson.loadConverting(isList: true); expect(decoded, simpleObj); // --- // Cleans up test. await persist.delete(); }); }); } ================================================ FILE: test/local_persist_test.dart ================================================ // Please run this test file by itself, not together with other tests. import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:async_redux/async_redux.dart'; import 'package:async_redux/local_persist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'test_utils.dart'; enum files { abc, xyz } void main() { WidgetsFlutterBinding.ensureInitialized(); group('Do not run on CI', skip: isCI, () { test('Encode and decode state.', () async { // List simpleObjs = [ 'Hello', 'How are you?', [ 1, 2, 3, {'name': 'John'} ], 42, true, false ]; Uint8List encoded = LocalPersist.encode(simpleObjs); List decoded = LocalPersist.decode(encoded); expect(decoded, simpleObjs); expect( decoded.map((obj) => "$obj (${obj.runtimeType})").join("\n"), 'Hello (String)\n' 'How are you? (String)\n' '[1, 2, 3, {name: John}] (List)\n' '42 (int)\n' 'true (bool)\n' 'false (bool)'); }); test('Save and load state.', () async { // // Use a random number to make sure it's not checking already saved files. int randNumber = Random().nextInt(100000); List simpleObjs = [ 'Goodbye', '"Life is what happens\n\rwhen you\'re busy making other plans." -John Lennon', [ 100, 200, {"name": "João"} ], true, randNumber, ]; var persist = LocalPersist("abc"); await persist.save(simpleObjs); List decoded = (await persist.load())!; expect(decoded, simpleObjs); expect( decoded.map((obj) => "$obj (${obj.runtimeType})").join("\n"), 'Goodbye (String)\n' '"Life is what happens\n\rwhen you\'re busy making other plans." -John Lennon (String)\n' '[100, 200, {name: João}] (List)\n' 'true (bool)\n' '$randNumber (int)'); // Cleans up test. await persist.delete(); }); test('Test file can be defined by String or enum.', () async { // File file = await (LocalPersist("abc").file()); expect( file.path.endsWith("\\db\\abc.db") || file.path.endsWith("/db/abc.db"), isTrue); file = await (LocalPersist(files.abc).file()); expect( file.path.endsWith("\\db\\abc.db") || file.path.endsWith("/db/abc.db"), isTrue); file = await (LocalPersist(files.xyz, dbSubDir: "kkk").file()); expect( file.path.endsWith("\\kkk\\xyz.db") || file.path.endsWith("/kkk/xyz.db"), isTrue); }); test('Test dbDir and subDirs.', () async { // File file = await (LocalPersist("xyz").file()); expect(file.path.endsWith("\\xyz.db") || file.path.endsWith("/xyz.db"), isTrue); file = await (LocalPersist("xyz", dbSubDir: "kkk").file()); expect( file.path.endsWith("\\kkk\\xyz.db") || file.path.endsWith("/kkk/xyz.db"), isTrue); file = await (LocalPersist("xyz", dbSubDir: "kkk", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\kkk\\mno\\xyz.db") || file.path.endsWith("/kkk/mno/xyz.db"), isTrue); file = await (LocalPersist("xyz", dbSubDir: "kkk", subDirs: ["m", "n", "o"]) .file()); expect( file.path.endsWith("\\kkk\\m\\n\\o\\xyz.db") || file.path.endsWith("/kkk/m/n/o/xyz.db"), isTrue); file = await (LocalPersist("xyz", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\db\\mno\\xyz.db") || file.path.endsWith("/db/mno/xyz.db"), isTrue); file = await (LocalPersist("xyz", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\db\\m\\n\\o\\xyz.db") || file.path.endsWith("/db/m/n/o/xyz.db"), isTrue); String saveDefaultDbSubDir = LocalPersist.defaultDbSubDir; LocalPersist.defaultDbSubDir = "myDir"; file = await (LocalPersist("xyz", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\myDir\\mno\\xyz.db") || file.path.endsWith("/myDir/mno/xyz.db"), isTrue); file = await (LocalPersist("xyz", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\myDir\\m\\n\\o\\xyz.db") || file.path.endsWith("/myDir/m/n/o/xyz.db"), isTrue); LocalPersist.defaultDbSubDir = ""; file = await (LocalPersist("xyz", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\mno\\xyz.db") || file.path.endsWith("/mno/xyz.db"), isTrue); expect( file.path.endsWith("\\db\\mno\\xyz.db") || file.path.endsWith("/db/mno/xyz.db"), isFalse); print('file.path = ${file.path}'); file = await (LocalPersist("xyz", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\m\\n\\o\\xyz.db") || file.path.endsWith("/m/n/o/xyz.db"), isTrue); expect( file.path.endsWith("\\db\\m\\n\\o\\xyz.db") || file.path.endsWith("/db/m/n/o/xyz.db"), isFalse); LocalPersist.defaultDbSubDir = ""; file = await (LocalPersist("xyz", subDirs: ["mno"]).file()); expect( file.path.endsWith("\\mno\\xyz.db") || file.path.endsWith("/mno/xyz.db"), isTrue); expect( file.path.endsWith("\\db\\mno\\xyz.db") || file.path.endsWith("/db/mno/xyz.db"), isFalse); print('file.path = ${file.path}'); file = await (LocalPersist("xyz", subDirs: ["m", "n", "o"]).file()); expect( file.path.endsWith("\\m\\n\\o\\xyz.db") || file.path.endsWith("/m/n/o/xyz.db"), isTrue); expect( file.path.endsWith("\\db\\m\\n\\o\\xyz.db") || file.path.endsWith("/db/m/n/o/xyz.db"), isFalse); LocalPersist.defaultDbSubDir = saveDefaultDbSubDir; }); test('Add objects to save, and load from file name.', () async { // // User random numbers to make sure it's not checking already saved files. var rand = Random(); int randNumber1 = rand.nextInt(1000); int randNumber2 = rand.nextInt(1000); int randNumber3 = rand.nextInt(1000); var persist = LocalPersist("xyz"); await persist.save([randNumber1, randNumber2, randNumber3]); List decoded = (await persist.load())!; expect(decoded, [randNumber1, randNumber2, randNumber3]); expect( decoded.map((obj) => "$obj (${obj.runtimeType})").join("\n"), '$randNumber1 (int)\n' '$randNumber2 (int)\n' '$randNumber3 (int)'); // Cleans up test. await persist.delete(); }); test('Test appending, then loading.', () async { // // User random numbers to make sure it's not checking already saved files. var rand = Random(); int randNumber1 = rand.nextInt(1000); int randNumber2 = rand.nextInt(1000); var persist = LocalPersist("lmn"); await persist.save(["Hello", randNumber1], append: false); await persist.save(["There", randNumber2], append: true); var simpleObjs = [ 35, false, { "x": 1, "y": [1, 2] } ]; await persist.save(simpleObjs, append: true); List decoded = (await persist.load())!; expect(decoded, [ "Hello", randNumber1, "There", randNumber2, 35, false, { "x": 1, "y": [1, 2] } ]); expect( LocalPersist.simpleObjsToString(decoded), 'Hello (String)\n' '$randNumber1 (int)\n' 'There (String)\n' '$randNumber2 (int)\n' '35 (int)\n' 'false (bool)\n' '{x: 1, y: [1, 2]} (_Map)'); // Cleans up test. await persist.delete(); }); test('Test create, append, overwrite and delete the file.', () async { // var persist = LocalPersist("klm"); // Create. await persist.save([123], append: false); var decoded = await persist.load(); expect(decoded, [123]); // Append. await persist.save([456], append: true); decoded = await persist.load(); expect(decoded, [123, 456]); // Overwrite. await persist.save([789], append: false); decoded = await persist.load(); expect(decoded, [789]); // Delete. File file = await (persist.file()); expect(file.existsSync(), true); await persist.delete(); expect(file.existsSync(), false); }); test("Load/Length/Exists file that doesn't exist, or exists and is empty.", () async { // // File doesn't exist. var persist = LocalPersist("doesNotExist"); expect(await persist.load(), isNull); expect(await persist.length(), 0); expect(await persist.exists(), false); // File exists and is empty. persist = LocalPersist("my_file"); await persist.save([]); expect(await persist.load(), []); expect(await persist.length(), 0); expect(await persist.exists(), true); }); test("Deletes a file that exists or doesn't exist.", () async { // // File doesn't exist. var persist = LocalPersist("doesNotExist"); expect(await persist.delete(), isFalse); // File exists and is deleted. persist = LocalPersist("my_file"); await persist.save([]); expect(await persist.delete(), isTrue); }); test('Load as object.', () async { // // Use a random number to make sure it's not checking already saved files. int randNumber = Random().nextInt(100000); List simpleObjs = [ { "one": 1, "two": randNumber, } ]; var persist = LocalPersist("obj"); await persist.save(simpleObjs); Map decoded = (await persist.loadAsObj())!; expect(decoded, simpleObjs[0]); // Cleans up test. await persist.delete(); }); test('Load many object as single object.', () async { // List simpleObjs = [ { "one": 1, "two": 2, }, { "three": 1, "four": 2, } ]; var persist = LocalPersist("obj"); await persist.save(simpleObjs); dynamic error; try { await persist.loadAsObj(); } catch (_error) { error = _error; } expect( error, PersistException( "Not a single object: [{one: 1, two: 2}, {three: 1, four: 2}]")); // Cleans up test. await persist.delete(); }); test('Load as object (map) something which is not an object.', () async { // List simpleObjs = ["hey"]; var persist = LocalPersist("obj"); await persist.save(simpleObjs); dynamic error; try { await persist.loadAsObj(); } catch (_error) { error = _error; } expect(error, PersistException("Not an object: hey")); // Cleans up test. await persist.delete(); }); test('Encode and decode as JSON.', () async { // List simpleObjs = [ 'Hello', 'How are you?', [ 1, 2, 3, {'name': 'John'} ], 42, true, false ]; Uint8List encoded = LocalPersist.encodeJson(simpleObjs); Object? decoded = LocalPersist.decodeJson(encoded); expect(decoded, simpleObjs); expect( (decoded as List) .map((obj) => "$obj (${obj.runtimeType})") .join("\n"), 'Hello (String)\n' 'How are you? (String)\n' '[1, 2, 3, {name: John}] (List)\n' '42 (int)\n' 'true (bool)\n' 'false (bool)'); }); test('Save and load state into/from JSON.', () async { // // Use a random number to make sure it's not checking already saved files. int randNumber = Random().nextInt(100000); List simpleObjs = [ 'Goodbye', '"Life is what happens\n\rwhen you\'re busy making other plans." -John Lennon', [ 100, 200, {"name": "João"} ], true, randNumber, ]; var persist = LocalPersist("abc"); await persist.saveJson(simpleObjs); Object? decoded = await persist.loadJson(); expect(decoded, simpleObjs); expect( (decoded as List) .map((obj) => "$obj (${obj.runtimeType})") .join("\n"), 'Goodbye (String)\n' '"Life is what happens\n\rwhen you\'re busy making other plans." -John Lennon (String)\n' '[100, 200, {name: João}] (List)\n' 'true (bool)\n' '$randNumber (int)'); // Cleans up test. await persist.delete(); }); test('Save and load a single string into/from JSON.', () async { // Object simpleObjs = 'Goodbye'; var persist = LocalPersist("abc"); await persist.saveJson(simpleObjs); Object? decoded = await persist.loadJson(); expect(decoded, simpleObjs); expect(decoded, 'Goodbye'); // Cleans up test. await persist.delete(); }); }); } ================================================ FILE: test/mock_build_context_test.dart ================================================ import "package:async_redux/async_redux.dart"; import "package:flutter/material.dart"; import "package:flutter_test/flutter_test.dart"; void main() { group('MockBuildContext', () { test('allows testing widgets with context.state extension', () { var store = Store(initialState: AppState(name: 'Mark', age: 30)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; expect(widget.name, 'Mark'); }); test('onChange callback dispatches ChangeName action', () async { var store = Store(initialState: AppState(name: 'Initial', age: 25)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; expect(store.state.name, 'Initial'); // Trigger action `ChangeName('John')` via onChange callback. widget.onChange(); // State should be updated (synchronous action completes immediately). expect(store.state.name, 'John'); }); test('rebuilding widget after state change shows new name', () async { var store = Store(initialState: AppState(name: 'Original', age: 20)); var context = MockBuildContext(store); // Build widget with original state. var widget1 = MyConnector().build(context) as MyWidget; expect(widget1.name, 'Original'); // Dispatch action to change name (synchronous action completes immediately). await store.dispatchAndWait(ChangeName('Updated')); // Rebuild widget - should reflect new state. var widget2 = MyConnector().build(context) as MyWidget; expect(widget2.name, 'Updated'); }); test('context.read() returns state without rebuilding', () { var store = Store(initialState: AppState(name: 'ReadTest', age: 35)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // In MockBuildContext, read() should work the same as state. expect(widget.nameFromRead, 'ReadTest'); expect(widget.nameFromRead, widget.name); }); test('context.select() selects specific part of state', () { var store = Store(initialState: AppState(name: 'SelectTest', age: 40)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // select should return the selected part of the state expect(widget.nameFromSelect, 'SelectTest'); expect(widget.nameFromSelect, widget.name); }); test('context.event() returns null when event is spent', () { var store = Store(initialState: AppState(name: 'EventTest', age: 50)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Initial event is spent, should return null expect(widget.nameFromEvent, null); }); test('context.event() returns event value when event is dispatched', () async { var store = Store(initialState: AppState(name: 'Initial', age: 18)); var context = MockBuildContext(store); // Build widget - event is spent var widget1 = MyConnector().build(context) as MyWidget; expect(widget1.nameFromEvent, null); // Dispatch action with event await store.dispatchAndWait(ChangeNameWithEvent('NewName')); // Rebuild widget - event should be consumed and return the value var widget2 = MyConnector().build(context) as MyWidget; expect(widget2.nameFromEvent, 'NewName'); expect(widget2.name, 'NewName'); }); test('context.event() consumes event only once', () async { var store = Store(initialState: AppState(name: 'Initial', age: 22)); var context = MockBuildContext(store); // Dispatch action with event await store.dispatchAndWait(ChangeNameWithEvent('FirstEvent')); // First build - event should be consumed var widget1 = MyConnector().build(context) as MyWidget; expect(widget1.nameFromEvent, 'FirstEvent'); // Second build - event is now spent var widget2 = MyConnector().build(context) as MyWidget; expect(widget2.nameFromEvent, null); }); test('all context methods work together in MyConnector', () async { var store = Store(initialState: AppState(name: 'AllMethods', age: 28)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // All methods should return the same name expect(widget.name, 'AllMethods'); expect(widget.nameFromRead, 'AllMethods'); expect(widget.nameFromSelect, 'AllMethods'); expect(widget.nameFromEvent, null); // Event is spent // Dispatch action with event await store.dispatchAndWait(ChangeNameWithEvent('UpdatedWithEvent')); // Rebuild and verify all methods work var widget2 = MyConnector().build(context) as MyWidget; expect(widget2.name, 'UpdatedWithEvent'); expect(widget2.nameFromRead, 'UpdatedWithEvent'); expect(widget2.nameFromSelect, 'UpdatedWithEvent'); expect(widget2.nameFromEvent, 'UpdatedWithEvent'); }); test( 'context.read() and context.state return same value after state change', () async { var store = Store(initialState: AppState(name: 'Before', age: 33)); var context = MockBuildContext(store); // Change state await store.dispatchAndWait(ChangeName('After')); // Build widget and verify both methods return updated state var widget = MyConnector().build(context) as MyWidget; expect(widget.name, 'After'); expect(widget.nameFromRead, 'After'); expect(widget.name, widget.nameFromRead); }); test('context.dispatchAll() dispatches multiple actions', () async { var store = Store(initialState: AppState(name: 'Initial', age: 0)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Dispatch multiple actions via MyConnector widget.onDispatchAll(); // Both actions should be applied expect(store.state.name, 'Updated'); expect(store.state.age, 42); }); test('context.dispatchSync() dispatches synchronous action', () { var store = Store(initialState: AppState(name: 'Initial', age: 10)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Dispatch sync action via MyConnector widget.onDispatchSync(); // Action completes immediately expect(store.state.name, 'Sync'); }); test('context.dispatchAndWait() waits for async action then dispatches', () async { var store = Store(initialState: AppState(name: 'Initial', age: 0)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Age should be 0 initially expect(store.state.age, 0); // Dispatch async action via MyConnector - waits for WaitAndChangeAge(10), then DuplicateAge await widget.onDispatchAndWait(); // After waiting, age should be 10 * 2 = 20 expect(store.state.age, 20); }); test('context.dispatchAndWaitAll() waits for all actions then dispatches', () async { var store = Store(initialState: AppState(name: 'Initial', age: 0)); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Initial values expect(store.state.age, 0); expect(store.state.name, 'Initial'); // Dispatch multiple async actions - waits for both, then DuplicateAge await widget.onDispatchAndWaitAll(); // After waiting, age should be 5 * 2 = 10, name should be 'AsyncAll' expect(store.state.age, 10); expect(store.state.name, 'AsyncAll'); }); test('context.env returns store environment', () { var env = TestEnvironment(apiUrl: 'https://api.test.com'); var store = Store( initialState: AppState(name: 'Test', age: 45), environment: env, ); var context = MockBuildContext(store); var widget = MyConnector().build(context) as MyWidget; // Get environment via MyConnector expect(widget.environment, env); expect(widget.environment?.apiUrl, 'https://api.test.com'); }); }); } // Define AppState with name, age fields and event class AppState { final String name; final int age; final Event nameChangedEvent; AppState({ required this.name, required this.age, Event? nameChangedEvent, }) : nameChangedEvent = nameChangedEvent ?? Event.spent(); AppState copy({ String? name, int? age, Event? nameChangedEvent, }) => AppState( name: name ?? this.name, age: age ?? this.age, nameChangedEvent: nameChangedEvent ?? this.nameChangedEvent, ); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && name == other.name && age == other.age && nameChangedEvent == other.nameChangedEvent; @override int get hashCode => name.hashCode ^ age.hashCode ^ nameChangedEvent.hashCode; } // Define extension for BuildContext extension BuildContextExtension on BuildContext { AppState get state => getState(); AppState read() => getRead(); R select(R Function(AppState state) selector) => getSelect(selector); R? event(Evt Function(AppState state) selector) => getEvent(selector); TestEnvironment? get env => getEnvironment() as TestEnvironment?; } // Define ChangeName action class ChangeName extends ReduxAction { final String newName; ChangeName(this.newName); @override AppState reduce() => state.copy(name: newName); } // Define ChangeAge action class ChangeAge extends ReduxAction { final int newAge; ChangeAge(this.newAge); @override AppState reduce() => state.copy(age: newAge); } // Define WaitAndChangeAge - async action that waits 200ms class WaitAndChangeAge extends ReduxAction { final int newAge; WaitAndChangeAge(this.newAge); @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 200)); return state.copy(age: newAge); } } // Define DuplicateAge - doubles the current age class DuplicateAge extends ReduxAction { @override AppState reduce() => state.copy(age: state.age * 2); } // Define ChangeNameWithEvent action that also triggers an event class ChangeNameWithEvent extends ReduxAction { final String newName; ChangeNameWithEvent(this.newName); @override AppState reduce() => state.copy( name: newName, nameChangedEvent: Event(newName), ); } // Define MyWidget - the dumb widget class MyWidget extends StatelessWidget { final String name; final String nameFromRead; final String nameFromSelect; final String? nameFromEvent; final VoidCallback onChange; final VoidCallback onDispatchAll; final VoidCallback onDispatchSync; final Future Function() onDispatchAndWait; final Future Function() onDispatchAndWaitAll; final TestEnvironment? environment; const MyWidget({ Key? key, required this.name, required this.nameFromRead, required this.nameFromSelect, required this.nameFromEvent, required this.onChange, required this.onDispatchAll, required this.onDispatchSync, required this.onDispatchAndWait, required this.onDispatchAndWaitAll, required this.environment, }) : super(key: key); @override Widget build(BuildContext context) { return Column( children: [ Text(name), Text(nameFromRead), Text(nameFromSelect), if (nameFromEvent != null) Text(nameFromEvent!), ElevatedButton( onPressed: onChange, child: const Text('Change Name'), ), ], ); } } // Define MyConnector - the smart widget using context extensions class MyConnector extends StatelessWidget { @override Widget build(BuildContext context) { return MyWidget( name: context.state.name, nameFromRead: context.read().name, nameFromSelect: context.select((AppState state) => state.name), nameFromEvent: context.event((AppState state) => state.nameChangedEvent), onChange: () => context.dispatch(ChangeName('John')), onDispatchAll: () => context.dispatchAll([ ChangeName('Updated'), ChangeAge(42), ]), onDispatchSync: () => context.dispatchSync(ChangeName('Sync')), onDispatchAndWait: () async { await context.dispatchAndWait(WaitAndChangeAge(10)); context.dispatch(DuplicateAge()); }, onDispatchAndWaitAll: () async { await context.dispatchAndWaitAll([ WaitAndChangeAge(5), ChangeName('AsyncAll'), ]); context.dispatch(DuplicateAge()); }, environment: context.env, ); } } // Define TestEnvironment for testing getEnvironment class TestEnvironment { final String apiUrl; TestEnvironment({required this.apiUrl}); } ================================================ FILE: test/mock_store_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @immutable class AppState { final String text; AppState(this.text); } class MyAction extends ReduxAction { int value; MyAction(this.value); @override AppState reduce() => AppState(state.text + value.toString()); } class MyAction1 extends MyAction { MyAction1() : super(1); } class MyAction2 extends MyAction { MyAction2() : super(2); } class MyAction3 extends MyAction { MyAction3() : super(3); } class MyAction4 extends MyAction { MyAction4() : super(4); } class MyAction5 extends MyAction { MyAction5() : super(5); } class MyMockAction extends MockAction { @override AppState reduce() => AppState(state.text + '[' + (action as MyAction).value.toString() + ']'); } void main() { StoreTester createMockStoreTester() { var store = MockStore(initialState: AppState("0")); return StoreTester.from(store); } test('Store: mock a single sync action.', () async { var store = MockStore(initialState: AppState("0")); expect(store.state.text, "0"); store.dispatch(MyAction1()); expect(store.state.text, "01"); // With mock: store = MockStore(initialState: AppState("0")); expect(store.state.text, "0"); store.addMock( MyAction1, (ReduxAction action, AppState state) => AppState(state.text + 'A'), ); store.dispatch(MyAction1()); expect(store.state.text, "0A"); }); test('StoreTester: mock a single sync action.', () async { // Without mock: var storeTester = createMockStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(MyAction1()); expect(storeTester.state.text, "01"); // With mock: storeTester = createMockStoreTester(); expect(storeTester.state.text, "0"); storeTester.addMock( MyAction1, (ReduxAction action, AppState state) => AppState(state.text + 'A')); storeTester.dispatch(MyAction1()); expect(storeTester.state.text, "0A"); }); test('Store: mock sync actions in different ways.', () async { // Without mock: var store = MockStore(initialState: AppState("0")); expect(store.state.text, "0"); store.dispatch(MyAction1()); store.dispatch(MyAction2()); store.dispatch(MyAction3()); store.dispatch(MyAction4()); store.dispatch(MyAction5()); expect(store.state.text, "012345"); // With mock: store = MockStore(initialState: AppState("0")); expect(store.state.text, "0"); store.addMocks({ /// 1) `null` to disable dispatching the action of a certain type. MyAction1: null, /// 2) A `MockAction` instance to dispatch that action instead, /// and provide the original action as a getter to the mocked action. MyAction2: MyMockAction(), /// 3) A `ReduxAction` instance to dispatch that mocked action instead. MyAction3: MyAction(7), /// 4) `ReduxAction Function(ReduxAction)` to create a mock /// from the original action, MyAction4: (ReduxAction action) => MyAction((action as MyAction).value + 4), /// 5) `St Function(ReduxAction, St)` to modify the state directly. MyAction5: (ReduxAction action, AppState state) => AppState(state.text + '|' + (action as MyAction).value.toString()), }); store.dispatch(MyAction1()); store.dispatch(MyAction2()); store.dispatch(MyAction3()); store.dispatch(MyAction4()); store.dispatch(MyAction5()); expect(store.state.text, "0[2]78|5"); }); test("Mock can't be of invalid type.", () async { var store = MockStore(initialState: AppState("0")); expect(store.state.text, "0"); store.addMocks({MyAction1: 123}); Object? error; try { store.dispatch(MyAction1()); } catch (_error) { error = _error; } expect(error, isNotNull); expect(error, const TypeMatcher()); expect( error.toString(), "Action of type `MyAction1` can't be mocked by a mock of type `int`.\n" "Valid mock types are:\n" "`null`\n" "`MockAction`\n" "`ReduxAction`\n" "`ReduxAction Function(ReduxAction)`\n" "`St Function(ReduxAction, St)`\n"); }); } ================================================ FILE: test/model_observer_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { // testWidgets( // "ModelObserver.", // (WidgetTester tester) async { // var modelObserver = DefaultModelObserver(); Store<_StateTest> store = Store<_StateTest>( initialState: _StateTest("A", 1), modelObserver: modelObserver, ); StoreProvider<_StateTest> provider = StoreProvider<_StateTest>( store: store, child: const _MyWidgetConnector(), ); await tester.pumpWidget(_TestApp(provider)); // A ➜ B expect(store.state.text, "A"); store.dispatch(_MyAction("B", 1)); expect(store.state.text, "B"); await tester.pump(); expect(modelObserver.previous, "A"); expect(modelObserver.current, "B"); // --- // B ➜ B expect(store.state.text, "B"); store.dispatch(_MyAction("B", 2)); expect(store.state.text, "B"); await tester.pump(); expect(modelObserver.previous, "B"); expect(modelObserver.current, "B"); // --- // B ➜ C expect(store.state.text, "B"); store.dispatch(_MyAction("C", 1)); expect(store.state.text, "C"); await tester.pump(); expect(modelObserver.previous, "B"); expect(modelObserver.current, "C"); // --- // C ➜ A expect(store.state.text, "C"); store.dispatch(_MyAction("D", 1)); expect(store.state.text, "D"); await tester.pump(); expect(modelObserver.previous, "C"); expect(modelObserver.current, "D"); }, ); } class _TestApp extends StatelessWidget { final StoreProvider<_StateTest> provider; _TestApp(this.provider); @override Widget build(BuildContext context) => provider; } @immutable class _StateTest { final String text; final int number; _StateTest(this.text, this.number); } class _MyWidgetConnector extends StatelessWidget { const _MyWidgetConnector(); @override Widget build(BuildContext context) => StoreConnector<_StateTest, String>( debug: this, converter: (Store<_StateTest> store) => store.state.text, builder: (BuildContext context, String model) => Container(), ); } class _MyAction extends ReduxAction<_StateTest> { String text; int number; _MyAction(this.text, this.number); @override _StateTest reduce() => _StateTest(text, number); } ================================================ FILE: test/navigate_action_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; late Store store; final navigatorKey = GlobalKey(); final routes = { "/": (BuildContext context) => MyPage(const Key("page1")), "/page2": (BuildContext context) => MyPage(const Key("page2")), "/page3": (BuildContext context) => MyPage(const Key("page3")), }; class AppState {} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return StoreProvider( store: store, child: MaterialApp( initialRoute: "/", routes: routes, navigatorKey: navigatorKey, ), ); } } class MyPage extends StatelessWidget { MyPage(Key key) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( body: Column( children: [ Text("Current route: ${NavigateAction.getCurrentNavigatorRouteName(context)}"), // RawMaterialButton( key: const Key("pushNamedPage2"), onPressed: () => store.dispatch(NavigateAction.pushNamed("/page2"))), // RawMaterialButton( key: const Key("pushNamedPage3"), onPressed: () => store.dispatch(NavigateAction.pushNamed("/page3"))), // RawMaterialButton( key: const Key("pushNamedAndRemoveAllPage1"), onPressed: () => store.dispatch(NavigateAction.pushNamedAndRemoveAll("/"))), // RawMaterialButton( key: const Key("pushReplacementNamedPage2"), onPressed: () => store.dispatch(NavigateAction.pushReplacementNamed("/page2"))), // RawMaterialButton( key: const Key("pushNamedAndRemoveUntilPage2"), onPressed: () => store.dispatch( NavigateAction.pushNamedAndRemoveUntil("/page2", (Route route) { return route.settings.name == "/"; }))), // RawMaterialButton( key: const Key("popUntilPage1"), onPressed: () => store.dispatch(NavigateAction.popUntilRouteName("/"))), // RawMaterialButton( key: const Key("pop"), onPressed: () => store.dispatch(NavigateAction.pop()), ), // ], ), ); } } void main() { setUp(() async { NavigateAction.setNavigatorKey(navigatorKey); store = Store(initialState: AppState()); }); final Finder page1Finder = find.byKey(const Key("page1")); final Finder page1IncludeIfOffstageFinder = find.byKey(const Key("page1"), skipOffstage: false); final Finder pushAndRemoveAllPage1Finder = find.byKey(const Key("pushNamedAndRemoveAllPage1")); final Finder popUntilPage1Finder = find.byKey(const Key("popUntilPage1")); final Finder page2Finder = find.byKey(const Key("page2")); final Finder page2IncludeIfOffstageFinder = find.byKey(const Key("page2"), skipOffstage: false); final Finder pushPage2Finder = find.byKey(const Key("pushNamedPage2")); final Finder pushReplacementPage2Finder = find.byKey(const Key("pushReplacementNamedPage2")); final Finder pushNamedAndRemoveUntilPage2Finder = find.byKey(const Key("pushNamedAndRemoveUntilPage2")); final Finder page3Finder = find.byKey(const Key("page3")); final Finder page3IncludeIfOffstageFinder = find.byKey(const Key("page3"), skipOffstage: false); final Finder pushPage3Finder = find.byKey(const Key("pushNamedPage3")); final Finder popFinder = find.byKey(const Key("pop")); testWidgets("pushNamed", (WidgetTester tester) async { await tester.pumpWidget(MyApp()); await tester.pumpAndSettle(); // check if initial page corresponds to initialRoute expect(find.text("Current route: /"), findsOneWidget); expect(page1Finder, findsOneWidget); expect(page2Finder, findsNothing); expect(page3Finder, findsNothing); // pushNamed to page 2 await tester.tap(pushPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1Finder, findsNothing); expect(page2Finder, findsOneWidget); expect(page3Finder, findsNothing); // pushNamed to page 3 await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1Finder, findsNothing); expect(page2Finder, findsNothing); expect(page3Finder, findsOneWidget); }); testWidgets("pushNamedAndRemoveAll", (WidgetTester tester) async { await tester.pumpWidget(MyApp()); await tester.pumpAndSettle(); // initial route expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 2 await tester.tap(pushPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 3 await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsOneWidget); // for fun, push page 3 again await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNWidgets(2)); // pushNamedAndRemoveAll back to page 1 await tester.tap(pushAndRemoveAllPage1Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); }); testWidgets("pushReplacementNamed", (WidgetTester tester) async { await tester.pumpWidget(MyApp()); await tester.pumpAndSettle(); // initial route expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 2 await tester.tap(pushPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 3 await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsOneWidget); // push page 2 and replace page 3 await tester.tap(pushReplacementPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNWidgets(2)); expect(page3IncludeIfOffstageFinder, findsNothing); }); testWidgets("pushNamedAndRemoveUntil", (WidgetTester tester) async { await tester.pumpWidget(MyApp()); await tester.pumpAndSettle(); // initial route expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 2 await tester.tap(pushPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 3 await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsOneWidget); // the current stack should be: // page 3 // page 2 // page 1 // if we push page 2 and replace until page 1,then the stack should be: // page 2 (pushed) // page 3 (removed) // page 2 (removed) // page 1 // which would result in a stack of // page 2 // page 1 await tester.tap(pushNamedAndRemoveUntilPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); }); testWidgets("pop", (WidgetTester tester) async { await tester.pumpWidget(MyApp()); await tester.pumpAndSettle(); // initial route expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 2 await tester.tap(pushPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 3 await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsOneWidget); // pop page 3 await tester.tap(popFinder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); // pop page 2 await tester.tap(popFinder); await tester.pumpAndSettle(); expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); }); // testWidgets("popUntil", (WidgetTester tester) async { await tester.pumpWidget(MyApp()); await tester.pumpAndSettle(); // initial route expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 2 await tester.tap(pushPage2Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page2"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsNothing); // push page 3 await tester.tap(pushPage3Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /page3"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsOneWidget); expect(page3IncludeIfOffstageFinder, findsOneWidget); // pop until page 1 await tester.tap(popUntilPage1Finder); await tester.pumpAndSettle(); expect(find.text("Current route: /"), findsOneWidget); expect(page1IncludeIfOffstageFinder, findsOneWidget); expect(page2IncludeIfOffstageFinder, findsNothing); expect(page3IncludeIfOffstageFinder, findsNothing); }); } ================================================ FILE: test/non_reentrant_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Non reentrant actions'); // ========================================================================== // Case 1: Sync action non-reentrant does not call itself // ========================================================================== Bdd(feature) .scenario('Sync action non-reentrant does not call itself.') .given('A SYNC action that calls itself.') .and('The action is non-reentrant.') .when('The action is dispatched.') .then('It runs once.') .and('Does not result in a stack overflow.') .run((_) async { var store = Store(initialState: State(1)); expect(store.state.count, 1); store.dispatchSync(NonReentrantSyncActionCallsItself()); expect(store.state.count, 2); }); // ========================================================================== // Case 2: Async action non-reentrant does not call itself // ========================================================================== Bdd(feature) .scenario('Async action non-reentrant does not call itself.') .given('An ASYNC action that calls itself.') .and('The action is non-reentrant.') .when('The action is dispatched.') .then('It runs once.') .and('Does not result in a stack overflow.') .run((_) async { var store = Store(initialState: State(1)); expect(store.state.count, 1); store.dispatch(NonReentrantAsyncActionCallsItself()); expect(store.state.count, 2); }); // ========================================================================== // Case 3: Async action non-reentrant blocks concurrent dispatches // ========================================================================== Bdd(feature) .scenario( 'Async action non-reentrant does not start before an action of the same type finished.') .given('An ASYNC action takes some time to finish.') .and('The action is non-reentrant.') .when('The action is dispatched.') .and('Another action of the same type is dispatched before the previous one finished.') .then('It runs only once.') .run((_) async { var store = Store(initialState: State(1)); // We start with count 1. expect(store.state.count, 1); expect(store.isWaiting(NonReentrantAsyncAction), false); // We dispatch an action that will wait for 100 millis and increment 10. store.dispatch(NonReentrantAsyncAction(10, 100)); expect(store.isWaiting(NonReentrantAsyncAction), true); // So far, we still have count 1. expect(store.state.count, 1); // We wait a little bit and dispatch ANOTHER action that will wait for 10 millis and increment 50. await Future.delayed(const Duration(milliseconds: 10)); store.dispatch(NonReentrantAsyncAction(50, 10)); expect(store.isWaiting(NonReentrantAsyncAction), true); // We wait for all actions to finish dispatching. await store.waitAllActions([]); expect(store.isWaiting(NonReentrantAsyncAction), false); // The only action that ran was the first one, which incremented by 10 (1+10 = 11). // The second action was aborted. expect(store.state.count, 11); }); // ========================================================================== // Case 4: NonReentrant allows dispatch after action completes // ========================================================================== Bdd(feature) .scenario('NonReentrant allows dispatch after action completes.') .given('An ASYNC non-reentrant action has completed.') .when('The same action type is dispatched again.') .then('It should run successfully.') .run((_) async { var store = Store(initialState: State(1)); // Dispatch first action await store.dispatchAndWait(NonReentrantAsyncAction(10, 50)); expect(store.state.count, 11); // After completion, we can dispatch again await store.dispatchAndWait(NonReentrantAsyncAction(5, 50)); expect(store.state.count, 16); }); // ========================================================================== // Case 5: NonReentrant releases key even on failure // ========================================================================== Bdd(feature) .scenario('NonReentrant releases key even when action fails.') .given('A non-reentrant action that throws an error.') .when('The action is dispatched and fails.') .then('A subsequent dispatch of the same action type should run.') .run((_) async { var store = Store(initialState: State(1)); // Dispatch action that will fail await store.dispatchAndWait(NonReentrantFailingAction()); expect(store.state.count, 1); // No change due to failure // After failure, we can dispatch again (key was released in after()) await store.dispatchAndWait(NonReentrantAsyncAction(10, 10)); expect(store.state.count, 11); }); // ========================================================================== // Case 6: Actions with nonReentrantKeyParams can run in parallel // ========================================================================== Bdd(feature) .scenario( 'Actions with different nonReentrantKeyParams can run in parallel.') .given('A non-reentrant action that uses nonReentrantKeyParams.') .when('Two actions with different params are dispatched concurrently.') .then('Both actions should run.') .run((_) async { var store = Store(initialState: State(0)); // Dispatch two actions with different itemIds - they should both run store.dispatch(NonReentrantWithParams('A', 10, 100)); store.dispatch(NonReentrantWithParams('B', 20, 100)); // Wait for both to complete await store.waitAllActions([]); // Both should have run: 0 + 10 + 20 = 30 expect(store.state.count, 30); }); // ========================================================================== // Case 7: Actions with same nonReentrantKeyParams block each other // ========================================================================== Bdd(feature) .scenario('Actions with same nonReentrantKeyParams block each other.') .given('A non-reentrant action that uses nonReentrantKeyParams.') .when('Two actions with the same params are dispatched concurrently.') .then('Only the first action should run.') .run((_) async { var store = Store(initialState: State(0)); // Dispatch two actions with the same itemId store.dispatch(NonReentrantWithParams('A', 10, 100)); // Wait a bit to ensure first action started await Future.delayed(const Duration(milliseconds: 10)); // This should be aborted because 'A' is already running store.dispatch(NonReentrantWithParams('A', 50, 10)); // Wait for all to complete await store.waitAllActions([]); // Only first should have run: 0 + 10 = 10 expect(store.state.count, 10); }); // ========================================================================== // Case 8: Different action types with same computeNonReentrantKey block each other // ========================================================================== Bdd(feature) .scenario( 'Different action types with same computeNonReentrantKey block each other.') .given('Two different action types that share the same non-reentrant key.') .when('Both actions are dispatched concurrently.') .then('Only the first action should run.') .run((_) async { var store = Store(initialState: State(0)); // Dispatch first action with shared key store.dispatch(NonReentrantSharedKey1(10, 100)); // Wait a bit to ensure first action started await Future.delayed(const Duration(milliseconds: 10)); // Dispatch second action type with same shared key - should be aborted store.dispatch(NonReentrantSharedKey2(50, 10)); // Wait for all to complete await store.waitAllActions([]); // Only first should have run: 0 + 10 = 10 expect(store.state.count, 10); }); // ========================================================================== // Case 9: After first action completes, second action type with shared key can run // ========================================================================== Bdd(feature) .scenario( 'After first action completes, second action type with shared key can run.') .given('Two different action types that share the same non-reentrant key.') .when('The first action completes.') .then('The second action type can run.') .run((_) async { var store = Store(initialState: State(0)); // Dispatch and wait for first action await store.dispatchAndWait(NonReentrantSharedKey1(10, 50)); expect(store.state.count, 10); // Now second action type with same key should run await store.dispatchAndWait(NonReentrantSharedKey2(20, 50)); expect(store.state.count, 30); }); // ========================================================================== // Case 10: Multiple concurrent dispatches with various params // ========================================================================== Bdd(feature) .scenario('Multiple concurrent dispatches with various params.') .given('Multiple non-reentrant actions with different params.') .when('They are dispatched concurrently.') .then( 'Actions with different params run, actions with same params are blocked.') .run((_) async { var store = Store(initialState: State(0)); // Dispatch multiple actions: // - Two with param 'A' (second should be blocked) // - Two with param 'B' (second should be blocked) // - One with param 'C' (should run) store.dispatch(NonReentrantWithParams('A', 1, 100)); store.dispatch(NonReentrantWithParams('B', 2, 100)); store.dispatch(NonReentrantWithParams('C', 4, 100)); await Future.delayed(const Duration(milliseconds: 10)); store.dispatch(NonReentrantWithParams('A', 8, 10)); // blocked store.dispatch(NonReentrantWithParams('B', 16, 10)); // blocked await store.waitAllActions([]); // Only A(1), B(2), C(4) should have run: 0 + 1 + 2 + 4 = 7 expect(store.state.count, 7); }); // ========================================================================== // Case 11: NonReentrant action key is released after error in reduce // ========================================================================== Bdd(feature) .scenario('NonReentrant action key is released after error in reduce.') .given('A non-reentrant action with params that throws.') .when('The action fails.') .then('The key is released and another action with same params can run.') .run((_) async { var store = Store(initialState: State(0)); // Dispatch action with param 'X' that fails await store.dispatchAndWait(NonReentrantWithParamsFails('X')); expect(store.state.count, 0); // No change // Now dispatch another action with same param - should run await store.dispatchAndWait(NonReentrantWithParams('X', 10, 10)); expect(store.state.count, 10); }); // ========================================================================== // Case 12: Default nonReentrantKeyParams returns null // ========================================================================== Bdd(feature) .scenario('Default nonReentrantKeyParams returns null.') .given('A non-reentrant action without overriding nonReentrantKeyParams.') .when('The action is dispatched twice concurrently.') .then('The second dispatch is blocked based on runtimeType.') .run((_) async { var store = Store(initialState: State(1)); // These use default key (runtimeType, null) store.dispatch(NonReentrantAsyncAction(10, 100)); await Future.delayed(const Duration(milliseconds: 10)); store.dispatch(NonReentrantAsyncAction(50, 10)); // Should be blocked await store.waitAllActions([]); // Only first ran: 1 + 10 = 11 expect(store.state.count, 11); }); } // ============================================================================= // Test state and actions // ============================================================================= class State { final int count; State(this.count); @override String toString() => 'State($count)'; } class NonReentrantSyncActionCallsItself extends ReduxAction with NonReentrant { @override State reduce() { dispatch(NonReentrantSyncActionCallsItself()); return State(state.count + 1); } } class NonReentrantAsyncActionCallsItself extends ReduxAction with NonReentrant { @override Future reduce() async { dispatch(NonReentrantSyncActionCallsItself()); return State(state.count + 1); } } class NonReentrantAsyncAction extends ReduxAction with NonReentrant { final int increment; final int delayMillis; NonReentrantAsyncAction(this.increment, this.delayMillis); @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMillis)); return State(state.count + increment); } } /// Action that always fails - used to test that key is released on error. class NonReentrantFailingAction extends ReduxAction with NonReentrant { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Intentional failure'); } } /// Action that uses nonReentrantKeyParams to differentiate by itemId. class NonReentrantWithParams extends ReduxAction with NonReentrant { final String itemId; final int increment; final int delayMillis; NonReentrantWithParams(this.itemId, this.increment, this.delayMillis); @override Object? nonReentrantKeyParams() => itemId; @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMillis)); return State(state.count + increment); } } /// Action that uses nonReentrantKeyParams and always fails. class NonReentrantWithParamsFails extends ReduxAction with NonReentrant { final String itemId; NonReentrantWithParamsFails(this.itemId); @override Object? nonReentrantKeyParams() => itemId; @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Intentional failure'); } } /// First action type that uses a shared non-reentrant key via computeNonReentrantKey. class NonReentrantSharedKey1 extends ReduxAction with NonReentrant { final int increment; final int delayMillis; NonReentrantSharedKey1(this.increment, this.delayMillis); @override Object computeNonReentrantKey() => 'sharedKey'; @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMillis)); return State(state.count + increment); } } /// Second action type that uses the same shared non-reentrant key. class NonReentrantSharedKey2 extends ReduxAction with NonReentrant { final int increment; final int delayMillis; NonReentrantSharedKey2(this.increment, this.delayMillis); @override Object computeNonReentrantKey() => 'sharedKey'; @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMillis)); return State(state.count + increment); } } ================================================ FILE: test/optimistic_command_mixin_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart' hide Retry; void main() { var feature = BddFeature('OptimisticCommand mixin'); Bdd(feature) .scenario('OptimisticCommand applies value, saves, and reloads.') .given('An action with OptimisticCommand mixin.') .when('The action is dispatched and sendCommandToServer succeeds.') .then('The optimistic value is applied immediately.') .and('The reloaded value is applied after save completes.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemAction('new_item'); // Track state changes during the action. action.stateChanges.add(store.state.items); await store.dispatchAndWait(action); // Final state should have the reloaded items. expect(store.state.items, ['reloaded']); expect(action.status.isCompletedOk, isTrue); }); Bdd(feature) .scenario( 'OptimisticCommand + Retry: retries sendCommandToServer only, no UI flickering.') .given('An action with both OptimisticCommand and Retry mixins.') .and('sendCommandToServer fails the first 2 times, then succeeds.') .when('The action is dispatched.') .then('The optimistic value is applied only once at the start.') .and('sendCommandToServer is retried until it succeeds.') .and('No rollback/re-apply flickering occurs during retries.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithRetry('new_item', failCount: 2); await store.dispatchAndWait(action); // Final state should have the reloaded items. expect(store.state.items, ['reloaded']); expect(action.status.isCompletedOk, isTrue); expect(action.attempts, 2); // Failed 2 times, then succeeded // CRITICAL: Verify NO flickering - optimistic value applied only once. // State changes tracked inside the action should show: // [optimistic] then [reloaded]. // NOT: [optimistic] [rollback] [optimistic] [rollback] [optimistic] [reloaded] expect(action.stateChangesLog.length, 2); // Only 2 state changes, no flickering expect(action.stateChangesLog[0], ['initial', 'new_item']); // Optimistic expect(action.stateChangesLog[1], ['reloaded']); // Reloaded }); Bdd(feature) .scenario( 'OptimisticCommand + Retry: rolls back only after all retries fail.') .given('An action with both OptimisticCommand and Retry mixins.') .and('sendCommandToServer always fails (maxRetries = 3).') .when('The action is dispatched.') .then('The optimistic value stays in place during all retry attempts.') .and('Rollback happens only after all retries are exhausted.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithRetryThatAlwaysFails('new_item'); await store.dispatchAndWait(action); // Final state should be rolled back (reloadFromServer doesn't throw, but rollback happens). // Note: The finally block still runs reloadFromServer even on failure. expect(action.status.isCompletedFailed, isTrue); expect(action.attempts, 4); // Initial + 3 retries // CRITICAL: Verify NO flickering - optimistic applied once, then reload runs in finally. // Without our fix, it would be: [opt] [roll] [opt] [roll] [opt] [roll] [opt] [roll] [reload] // With our fix: [opt] [roll] [reload] (rollback + reload in finally) expect(action.stateChangesLog.length, 3); expect(action.stateChangesLog[0], ['initial', 'new_item']); // Optimistic expect(action.stateChangesLog[1], ['initial']); // Rolled back after all retries failed expect(action.stateChangesLog[2], ['reloaded']); // Reload in finally }); Bdd(feature) .scenario( 'OptimisticCommand without Retry: normal behavior, no retry logic.') .given('An action with only OptimisticCommand mixin (no Retry).') .and('sendCommandToServer fails.') .when('The action is dispatched.') .then('The action fails immediately without retrying.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionThatFails('new_item'); await store.dispatchAndWait(action); // Note: reloadFromServer still runs in finally even on failure expect(action.status.isCompletedFailed, isTrue); expect(action.saveAttempts, 1); // Only 1 attempt, no retries }); Bdd(feature) .scenario('OptimisticCommand rolls back on failure.') .given('An action with OptimisticCommand mixin.') .when('The action is dispatched and sendCommandToServer fails.') .then('The optimistic value is rolled back to the initial value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionThatFailsWithStateLog('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // Verify the sequence: optimistic update, then rollback, then reload in finally. expect(action.stateChangesLog.length, 3); expect(action.stateChangesLog[0], ['initial', 'new_item']); // Optimistic expect(action.stateChangesLog[1], ['initial']); // Rolled back expect(action.stateChangesLog[2], ['reloaded']); // Reload in finally }); Bdd(feature) .scenario( 'OptimisticCommand does NOT rollback if state changed by another action.') .given('An action with OptimisticCommand mixin.') .and('Another action modifies the state during sendCommandToServer.') .when('The action fails.') .then('The optimistic value is NOT rolled back because state changed.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionThatFailsAfterStateChange('new_item', store); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // State should NOT be rolled back because another action changed it. // Final state should be 'changed_by_other' (from the other action) then 'reloaded'. // The rollback was skipped because state != optimistic value. expect(action.stateChangesLog[0], ['initial', 'new_item']); // Optimistic // No rollback occurred because state was changed by another action. // Finally block still runs reloadFromServer. expect(action.stateChangesLog.last, ['reloaded']); // Reload in finally expect(action.rollbackOccurred, isFalse); }); Bdd(feature) .scenario('OptimisticCommand without reloadFromServer implementation.') .given( 'An action with OptimisticCommand that does not implement reloadFromServer.') .when('The action is dispatched and sendCommandToServer succeeds.') .then('The reload step is skipped (no error).') .and('The state keeps the optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithoutReload('new_item'); await store.dispatchAndWait(action); // Final state should keep the optimistic value since reload was not implemented. expect(store.state.items, ['initial', 'new_item']); expect(action.status.isCompletedOk, isTrue); // Only one state change: the optimistic update. expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); Bdd(feature) .scenario( 'OptimisticCommand without reloadFromServer: rollback on failure.') .given( 'An action with OptimisticCommand that does not implement reloadFromServer.') .when('The action is dispatched and sendCommandToServer fails.') .then('The optimistic value is rolled back.') .and('The reload step is skipped (no error).') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithoutReloadThatFails('new_item'); await store.dispatchAndWait(action); // Final state should be rolled back to initial since save failed. expect(store.state.items, ['initial']); expect(action.status.isCompletedFailed, isTrue); // Two state changes: optimistic update, then rollback. expect(action.stateChangesLog.length, 2); expect(action.stateChangesLog[0], ['initial', 'new_item']); // Optimistic expect(action.stateChangesLog[1], ['initial']); // Rolled back }); Bdd(feature) .scenario( 'OptimisticCommand + Retry without reloadFromServer: no flickering.') .given( 'An action with OptimisticCommand and Retry, but no reloadFromServer.') .and('sendCommandToServer fails the first 2 times, then succeeds.') .when('The action is dispatched.') .then('No flickering occurs and state keeps optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithRetryNoReload('new_item', failCount: 2); await store.dispatchAndWait(action); // Final state should keep the optimistic value since reload was not implemented. expect(store.state.items, ['initial', 'new_item']); expect(action.status.isCompletedOk, isTrue); expect(action.attempts, 2); // Only one state change: the optimistic update (no reload). expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); // --------------------------------------------------------------------------- // Tests for overriding rollbackState // --------------------------------------------------------------------------- Bdd(feature) .scenario( 'Custom rollbackState marks item as failed instead of removing it.') .given('An action with OptimisticCommand that overrides rollbackState.') .when('The action fails.') .then('The custom rollback is applied (item marked as failed).') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithCustomRollback('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // State should have the item marked as failed. expect(store.state.items, ['initial', 'new_item (FAILED)']); // Verify error was passed to rollbackState. expect(action.capturedError, isA()); // Note: stateChangesLog only captures calls through applyValueToState. // Custom rollbackState returns a state directly, bypassing applyValueToState. // So we only see the optimistic update in the log. expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); Bdd(feature) .scenario('rollbackState returning null skips rollback.') .given( 'An action with OptimisticCommand that overrides rollbackState to return null.') .when('The action fails.') .then('No rollback occurs and state keeps the optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithRollbackReturningNull('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // State should keep the optimistic value (no rollback). expect(store.state.items, ['initial', 'new_item']); // Only one state change: the optimistic update. expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); // --------------------------------------------------------------------------- // Tests for overriding shouldRollback // --------------------------------------------------------------------------- Bdd(feature) .scenario('shouldRollback always true: rollback even when state changed.') .given( 'An action with OptimisticCommand that overrides shouldRollback to always return true.') .and('Another action modifies the state during sendCommandToServer.') .when('The action fails.') .then('The rollback happens even though state changed.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithAlwaysRollback('new_item', store); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // State should be rolled back to initial, overwriting the other action's change. expect(store.state.items, ['initial']); // State changes: optimistic, then rollback. expect(action.stateChangesLog.length, 2); expect(action.stateChangesLog[0], ['initial', 'new_item']); expect(action.stateChangesLog[1], ['initial']); }); Bdd(feature) .scenario('shouldRollback always false: never rollback.') .given( 'An action with OptimisticCommand that overrides shouldRollback to always return false.') .when('The action fails.') .then('No rollback occurs and state keeps the optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithNeverRollback('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // State should keep the optimistic value (no rollback). expect(store.state.items, ['initial', 'new_item']); // Only one state change: the optimistic update. expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); Bdd(feature) .scenario( 'shouldRollback conditional: rollback only for validation errors.') .given( 'An action with shouldRollback that returns false for network errors.') .when('The action fails with a network error.') .then('No rollback occurs.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithConditionalRollback('new_item', throwNetworkError: true); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // No rollback for network error. expect(store.state.items, ['initial', 'new_item']); expect(action.stateChangesLog.length, 1); }); Bdd(feature) .scenario('shouldRollback conditional: rollback for validation errors.') .given( 'An action with shouldRollback that returns true for validation errors.') .when('The action fails with a validation error.') .then('Rollback occurs.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithConditionalRollback('new_item', throwNetworkError: false); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // Rollback for validation error. expect(store.state.items, ['initial']); expect(action.stateChangesLog.length, 2); expect(action.stateChangesLog[0], ['initial', 'new_item']); expect(action.stateChangesLog[1], ['initial']); }); // --------------------------------------------------------------------------- // Tests for overriding shouldReload // --------------------------------------------------------------------------- Bdd(feature) .scenario('shouldReload returns false on success: no reload.') .given('An action with shouldReload that returns true only on error.') .when('The action succeeds.') .then('reloadFromServer is not called.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithConditionalReload('new_item', shouldFail: false); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.reloadWasCalled, isFalse); // State keeps optimistic value. expect(store.state.items, ['initial', 'new_item']); expect(action.stateChangesLog.length, 1); }); Bdd(feature) .scenario('shouldReload returns true on error: reload happens.') .given('An action with shouldReload that returns true only on error.') .when('The action fails.') .then('reloadFromServer is called and applied.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithConditionalReload('new_item', shouldFail: true); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); expect(action.reloadWasCalled, isTrue); // State is reloaded. expect(store.state.items, ['reloaded']); }); // --------------------------------------------------------------------------- // Tests for overriding shouldApplyReload // --------------------------------------------------------------------------- Bdd(feature) .scenario( 'shouldApplyReload returns true when state unchanged: reload applied.') .given( 'An action with shouldApplyReload that checks if state is unchanged.') .and('No other action modifies state during reload.') .when('The action succeeds.') .then('Reload result is applied.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithConditionalApplyReload( 'new_item', store, changeStateDuringReload: false, ); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // Reload was applied. expect(store.state.items, ['reloaded']); }); Bdd(feature) .scenario( 'shouldApplyReload returns false when state changed: reload skipped.') .given( 'An action with shouldApplyReload that checks if state is unchanged.') .and('Another action modifies state during reload.') .when('The action succeeds.') .then('Reload result is NOT applied to avoid overwriting newer changes.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithConditionalApplyReload( 'new_item', store, changeStateDuringReload: true, ); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // State was changed by other action, so reload was NOT applied. // The state should be 'changed_by_other' from ChangeStateAction. expect(store.state.items, ['changed_by_other']); }); // --------------------------------------------------------------------------- // Tests for overriding applyReloadResultToState // --------------------------------------------------------------------------- Bdd(feature) .scenario('Custom applyReloadResultToState transforms reload result.') .given( 'An action with applyReloadResultToState that transforms the reload result.') .and('reloadFromServer returns a map instead of a list.') .when('The action succeeds.') .then('The custom transformation is applied.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithCustomApplyReload('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // The custom applyReloadResultToState transformed the map and added 'TRANSFORMED'. expect(store.state.items, ['server_item1', 'server_item2', 'TRANSFORMED']); }); Bdd(feature) .scenario( 'applyReloadResultToState returning null skips applying reload.') .given('An action with applyReloadResultToState that returns null.') .when('The action succeeds and reload completes.') .then( 'The reload result is NOT applied and state keeps optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithApplyReloadReturningNull('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.reloadWasCalled, isTrue); // Reload was called but NOT applied (applyReloadResultToState returned null). // State keeps the optimistic value. expect(store.state.items, ['initial', 'new_item']); expect(action.stateChangesLog.length, 1); }); // --------------------------------------------------------------------------- // Missing edge cases / invariants // --------------------------------------------------------------------------- Bdd(feature) .scenario('OptimisticCommand: if reloadFromServer throws on success, ' 'the action fails with the reload error.') .given('An action with OptimisticCommand mixin.') .and('sendCommandToServer succeeds.') .and('reloadFromServer throws.') .when('The action is dispatched.') .then('The action fails (reload error is not swallowed).') .and( 'The optimistic value remains applied (reload did not overwrite it).') .note( 'This locks the intended behavior when reload fails but there was no prior error.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithReloadThatThrows('new_item'); await store.dispatchAndWait(action); // The action should fail because reloadFromServer threw. expect(action.status.isCompletedFailed, isTrue); // The error should be the reload error, not swallowed. expect(action.status.originalError.toString(), contains('Reload failed')); // The optimistic value remains applied (reload did not overwrite it). expect(store.state.items, ['initial', 'new_item']); }); Bdd(feature) .scenario('OptimisticCommand: if reloadFromServer throws on failure, ' 'the action fails with the original command error.') .given('An action with OptimisticCommand mixin.') .and('sendCommandToServer throws.') .and('reloadFromServer also throws.') .when('The action is dispatched.') .then('The action fails with the original sendCommandToServer error ' '(reload error does not replace it).') .and('Rollback behavior follows shouldRollback/rollbackState as usual.') .note('This ensures reload failure never hides the real command failure.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithBothCommandAndReloadThatThrow('new_item'); await store.dispatchAndWait(action); // The action should fail. expect(action.status.isCompletedFailed, isTrue); // The error should be the ORIGINAL command error, not the reload error. expect(action.status.originalError.toString(), contains('Command failed')); // Rollback should have happened (state rolled back to initial). expect(store.state.items, ['initial']); }); Bdd(feature) .scenario('OptimisticCommand: shouldReload can skip reload on error.') .given('An action with OptimisticCommand mixin.') .and('sendCommandToServer throws.') .and('shouldReload returns false when error != null.') .when('The action is dispatched.') .then('reloadFromServer is not called.') .and('Rollback behavior is still evaluated normally.') .note('This is different from "reload not implemented". ' 'It is "reload intentionally disabled by policy".') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithShouldReloadFalseOnError('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // reloadFromServer should NOT have been called. expect(action.reloadWasCalled, isFalse); // Rollback should still have happened normally. expect(store.state.items, ['initial']); }); Bdd(feature) .scenario( 'OptimisticCommand: shouldApplyReload can use the error parameter ' 'to skip applying reload on failure.') .given('An action with OptimisticCommand mixin.') .and('sendCommandToServer throws.') .and('reloadFromServer returns a value.') .and('shouldApplyReload returns false when error != null.') .when('The action is dispatched.') .then('reloadFromServer is called (because shouldReload returned true).') .and( 'The reload result is not applied (because shouldApplyReload returned false).') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithShouldApplyReloadFalseOnError('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // reloadFromServer WAS called. expect(action.reloadWasCalled, isTrue); // But the reload result was NOT applied (shouldApplyReload returned false). // State should be rolled back to initial, not 'reloaded'. expect(store.state.items, ['initial']); }); Bdd(feature) .scenario( 'OptimisticCommand: lastAppliedValue passed to shouldReload/shouldApplyReload ' 'is the rollbackValue when rollback was applied.') .given('An action with OptimisticCommand mixin.') .and('sendCommandToServer throws.') .and( 'Rollback is applied (shouldRollback returns true and rollbackState returns a non-null state).') .and( 'shouldReload captures the received lastAppliedValue and rollbackValue.') .when('The action is dispatched.') .then('lastAppliedValue equals rollbackValue when rollback happened.') .note( 'This verifies your bookkeeping: on error, the "last thing we applied" ' 'should reflect rollback, not the optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionCaptureLastAppliedOnError('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); // Verify the captured values. expect(action.capturedLastAppliedValue, isNotNull); expect(action.capturedRollbackValue, isNotNull); // lastAppliedValue should equal rollbackValue when rollback happened. expect(action.capturedLastAppliedValue, action.capturedRollbackValue); // And both should be the initial value (what we rolled back to). expect(action.capturedLastAppliedValue, ['initial']); }); Bdd(feature) .scenario( 'OptimisticCommand: lastAppliedValue passed to shouldReload/shouldApplyReload ' 'is the optimisticValue on success.') .given('An action with OptimisticCommand mixin.') .and('sendCommandToServer succeeds.') .and('shouldReload captures the received lastAppliedValue.') .when('The action is dispatched.') .then('lastAppliedValue equals optimisticValue.') .note( 'Ensures lastAppliedValue semantics are stable across success vs failure.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionCaptureLastAppliedOnSuccess('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // Verify the captured lastAppliedValue equals the optimisticValue. expect(action.capturedLastAppliedValue, isNotNull); expect(action.capturedOptimisticValue, isNotNull); expect(action.capturedLastAppliedValue, action.capturedOptimisticValue); // And rollbackValue should be null on success. expect(action.capturedRollbackValue, isNull); }); Bdd(feature) .scenario('OptimisticCommand: optimisticValue is computed exactly once, ' 'even when Retry retries sendCommandToServer.') .given('An action with OptimisticCommand and Retry mixins.') .and('optimisticValue increments a counter each time it is called.') .and('sendCommandToServer fails N times then succeeds.') .when('The action is dispatched.') .then('optimisticValue was called exactly once.') .and('sendCommandToServer was called N+1 times.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithOptimisticValueCounter('new_item', failCount: 3); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // optimisticValue should have been called exactly once. expect(action.optimisticValueCallCount, 1); // sendCommandToServer should have been called N+1 times (3 failures + 1 success = 4). expect(action.sendCommandCallCount, 4); }); Bdd(feature) .scenario( 'OptimisticCommand: sendCommandToServer receives the same optimisticValue ' 'instance that was applied to state.') .given('An action with OptimisticCommand mixin.') .and('optimisticValue returns an object whose identity can be checked.') .when('The action is dispatched.') .then( 'sendCommandToServer receives the same object instance returned by optimisticValue.') .note( 'This is useful if users build an optimistic payload object and want to reuse it in the command.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionCheckIdentity('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // The object passed to sendCommandToServer should be identical to the one returned by optimisticValue. expect(action.receivedValueInSendCommand, isNotNull); expect(action.createdOptimisticValue, isNotNull); expect( identical( action.receivedValueInSendCommand, action.createdOptimisticValue), isTrue); }); // --------------------------------------------------------------------------- // Tests for built-in non-reentrant behavior // --------------------------------------------------------------------------- Bdd(feature) .scenario('OptimisticCommand blocks concurrent dispatches.') .given('An OptimisticCommand action that takes some time to finish.') .when('The action is dispatched.') .and( 'Another action of the same type is dispatched before the previous one finished.') .then('The second dispatch is aborted.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); // Dispatch first action that takes 100ms. store.dispatch(OptimisticCommandSlowAction('item1', delayMillis: 100)); expect(store.isWaiting(OptimisticCommandSlowAction), true); // Wait a bit and dispatch another action of the same type. await Future.delayed(const Duration(milliseconds: 10)); store.dispatch(OptimisticCommandSlowAction('item2', delayMillis: 10)); // Wait for all actions to finish. await store.waitAllActions([]); expect(store.isWaiting(OptimisticCommandSlowAction), false); // Only the first action ran, adding 'item1'. // The second action was aborted. expect(store.state.items, ['initial', 'item1']); }); Bdd(feature) .scenario('OptimisticCommand allows dispatch after action completes.') .given('An OptimisticCommand action has completed.') .when('The same action type is dispatched again.') .then('It should run successfully.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); // Dispatch first action and wait for completion. await store .dispatchAndWait(OptimisticCommandSlowAction('item1', delayMillis: 10)); expect(store.state.items, ['initial', 'item1']); // After completion, we can dispatch again. await store .dispatchAndWait(OptimisticCommandSlowAction('item2', delayMillis: 10)); expect(store.state.items, ['initial', 'item1', 'item2']); }); Bdd(feature) .scenario('OptimisticCommand releases key even when action fails.') .given('An OptimisticCommand action that throws an error.') .when('The action is dispatched and fails.') .then('A subsequent dispatch of the same action type should run.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); // Dispatch action that will fail. await store.dispatchAndWait(OptimisticCommandFailingAction()); expect(store.state.items, ['initial']); // Rolled back due to failure. // After failure, we can dispatch again (key was released in after()). await store .dispatchAndWait(OptimisticCommandSlowAction('item1', delayMillis: 10)); expect(store.state.items, ['initial', 'item1']); }); Bdd(feature) .scenario( 'OptimisticCommand with different nonReentrantKeyParams can run in parallel.') .given('An OptimisticCommand action that uses nonReentrantKeyParams.') .when('Two actions with different params are dispatched concurrently.') .then('Both actions should run.') .run((_) async { var store = Store(initialState: AppState(items: [])); // Dispatch two actions with different itemIds - they should both run. store .dispatch(OptimisticCommandWithParams('A', 'valueA', delayMillis: 100)); store .dispatch(OptimisticCommandWithParams('B', 'valueB', delayMillis: 100)); // Wait for both to complete. await store.waitAllActions([]); // Both should have run. expect(store.state.items.length, 2); expect(store.state.items.contains('valueA'), isTrue); expect(store.state.items.contains('valueB'), isTrue); }); Bdd(feature) .scenario( 'OptimisticCommand with same nonReentrantKeyParams blocks each other.') .given('An OptimisticCommand action that uses nonReentrantKeyParams.') .when('Two actions with the same params are dispatched concurrently.') .then('Only the first action should run.') .run((_) async { var store = Store(initialState: AppState(items: [])); // Dispatch two actions with the same itemId. store.dispatch( OptimisticCommandWithParams('A', 'valueA1', delayMillis: 100)); // Wait a bit to ensure first action started. await Future.delayed(const Duration(milliseconds: 10)); // This should be aborted because 'A' is already running. store .dispatch(OptimisticCommandWithParams('A', 'valueA2', delayMillis: 10)); // Wait for all to complete. await store.waitAllActions([]); // Only first should have run. expect(store.state.items, ['valueA1']); }); Bdd(feature) .scenario( 'Different OptimisticCommand action types with same computeNonReentrantKey block each other.') .given( 'Two different OptimisticCommand action types that share the same non-reentrant key.') .when('Both actions are dispatched concurrently.') .then('Only the first action should run.') .run((_) async { var store = Store(initialState: AppState(items: [])); // Dispatch first action with shared key. store.dispatch(OptimisticCommandSharedKey1('value1', delayMillis: 100)); // Wait a bit to ensure first action started. await Future.delayed(const Duration(milliseconds: 10)); // Dispatch second action type with same shared key - should be aborted. store.dispatch(OptimisticCommandSharedKey2('value2', delayMillis: 10)); // Wait for all to complete. await store.waitAllActions([]); // Only first should have run. expect(store.state.items, ['value1']); }); Bdd(feature) .scenario( 'After first OptimisticCommand completes, second action type with shared key can run.') .given( 'Two different OptimisticCommand action types that share the same non-reentrant key.') .when('The first action completes.') .then('The second action type can run.') .run((_) async { var store = Store(initialState: AppState(items: [])); // Dispatch and wait for first action. await store.dispatchAndWait( OptimisticCommandSharedKey1('value1', delayMillis: 10)); expect(store.state.items, ['value1']); // Now second action type with same key should run. await store.dispatchAndWait( OptimisticCommandSharedKey2('value2', delayMillis: 10)); expect(store.state.items, ['value1', 'value2']); }); Bdd(feature) .scenario('OptimisticCommand key is released after error in reduce.') .given('An OptimisticCommand action with params that throws.') .when('The action fails.') .then('The key is released and another action with same params can run.') .run((_) async { var store = Store(initialState: AppState(items: [])); // Dispatch action with param 'X' that fails. await store.dispatchAndWait(OptimisticCommandWithParamsThatFails('X')); expect(store.state.items, []); // Rolled back. // Now dispatch another action with same param - should run. await store.dispatchAndWait( OptimisticCommandWithParams('X', 'valueX', delayMillis: 10)); expect(store.state.items, ['valueX']); }); Bdd(feature) .scenario( 'Multiple OptimisticCommand concurrent dispatches with various params.') .given('Multiple OptimisticCommand actions with different params.') .when('They are dispatched concurrently.') .then( 'Actions with different params run, actions with same params are blocked.') .run((_) async { var store = Store(initialState: AppState(items: [])); // Dispatch multiple actions: // - Two with param 'A' (second should be blocked) // - Two with param 'B' (second should be blocked) // - One with param 'C' (should run) store.dispatch(OptimisticCommandWithParams('A', 'A1', delayMillis: 100)); store.dispatch(OptimisticCommandWithParams('B', 'B1', delayMillis: 100)); store.dispatch(OptimisticCommandWithParams('C', 'C1', delayMillis: 100)); await Future.delayed(const Duration(milliseconds: 10)); store.dispatch( OptimisticCommandWithParams('A', 'A2', delayMillis: 10)); // blocked store.dispatch( OptimisticCommandWithParams('B', 'B2', delayMillis: 10)); // blocked await store.waitAllActions([]); // Only A1, B1, C1 should have run. expect(store.state.items.length, 3); expect(store.state.items.contains('A1'), isTrue); expect(store.state.items.contains('B1'), isTrue); expect(store.state.items.contains('C1'), isTrue); expect(store.state.items.contains('A2'), isFalse); expect(store.state.items.contains('B2'), isFalse); }); Bdd(feature) .scenario('OptimisticCommand cannot be combined with NonReentrant mixin.') .given('An action that combines OptimisticCommand and NonReentrant.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); // This should throw an assertion error due to incompatible mixins. expect( () => store.dispatch(OptimisticCommandWithNonReentrant('item')), throwsA(isA().having( (e) => e.message, 'message', contains('OptimisticCommand'), )), ); }); Bdd(feature) .scenario('OptimisticCommand cannot be combined with Throttle mixin.') .given('An action that combines OptimisticCommand and Throttle.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); // This should throw an assertion error due to incompatible mixins. expect( () => store.dispatch(OptimisticCommandWithThrottle('item')), throwsA(isA().having( (e) => e.message, 'message', contains('OptimisticCommand'), )), ); }); Bdd(feature) .scenario('OptimisticCommand cannot be combined with Fresh mixin.') .given('An action that combines OptimisticCommand and Fresh.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); // This should throw an assertion error due to incompatible mixins. expect( () => store.dispatch(OptimisticCommandWithFresh('item')), throwsA(isA().having( (e) => e.message, 'message', contains('OptimisticCommand'), )), ); }); // --------------------------------------------------------------------------- // Tests for DEFAULT shouldReload behavior (only reload on error) // --------------------------------------------------------------------------- Bdd(feature) .scenario('Default shouldReload: does NOT reload on success.') .given('An action with OptimisticCommand using default shouldReload.') .and('sendCommandToServer succeeds.') .when('The action is dispatched.') .then('reloadFromServer is NOT called.') .and('State keeps the optimistic value.') .note( 'The default shouldReload returns (error != null), so it skips reload on success.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithDefaultShouldReloadOnSuccess('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.reloadWasCalled, isFalse); // State keeps the optimistic value. expect(store.state.items, ['initial', 'new_item']); expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); Bdd(feature) .scenario('Default shouldReload: DOES reload on failure.') .given('An action with OptimisticCommand using default shouldReload.') .and('sendCommandToServer fails.') .when('The action is dispatched.') .then('reloadFromServer IS called.') .and('State is reloaded after rollback.') .note( 'The default shouldReload returns (error != null), so it reloads on failure.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithDefaultShouldReloadOnFailure('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); expect(action.reloadWasCalled, isTrue); // State was reloaded after rollback. expect(store.state.items, ['reloaded']); // State changes: optimistic, rollback, reload. expect(action.stateChangesLog.length, 3); expect(action.stateChangesLog[0], ['initial', 'new_item']); // Optimistic expect(action.stateChangesLog[1], ['initial']); // Rolled back expect(action.stateChangesLog[2], ['reloaded']); // Reloaded }); // --------------------------------------------------------------------------- // Tests for sendCommandToServer returning a value (applyServerResponseToState) // --------------------------------------------------------------------------- Bdd(feature) .scenario( 'sendCommandToServer returns value: applyServerResponseToState is called.') .given('An action with OptimisticCommand.') .and('sendCommandToServer returns a non-null value.') .when('The action is dispatched.') .then('applyServerResponseToState is called with the returned value.') .and('The server response is applied to the state.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithServerResponse('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.serverResponseApplied, isTrue); expect(action.receivedServerResponse, ['initial', 'new_item', 'server_confirmed']); // Final state should have the server-confirmed value. expect(store.state.items, ['initial', 'new_item', 'server_confirmed']); }); Bdd(feature) .scenario( 'sendCommandToServer returns null: applyServerResponseToState is NOT called.') .given('An action with OptimisticCommand.') .and('sendCommandToServer returns null.') .when('The action is dispatched.') .then('applyServerResponseToState is NOT called.') .and('State keeps the optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithNullServerResponse('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.applyServerResponseCalled, isFalse); // State keeps the optimistic value. expect(store.state.items, ['initial', 'new_item']); }); Bdd(feature) .scenario( 'applyServerResponseToState returns null: server response is NOT applied.') .given('An action with OptimisticCommand.') .and('sendCommandToServer returns a value.') .and('applyServerResponseToState returns null.') .when('The action is dispatched.') .then('The server response is received but NOT applied.') .and('State keeps the optimistic value.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithServerResponseReturningNull('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.applyServerResponseCalled, isTrue); // State keeps the optimistic value (server response was not applied). expect(store.state.items, ['initial', 'new_item']); }); Bdd(feature) .scenario('Server response is applied before reload.') .given('An action with OptimisticCommand.') .and( 'Both sendCommandToServer returns a value and reloadFromServer is implemented.') .when('The action is dispatched.') .then('applyServerResponseToState is called first.') .and('reloadFromServer is called second (if shouldReload returns true).') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithServerResponseAndReload('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // Verify the order: server response applied first, then reload. expect(action.operationOrder, ['applyServerResponse', 'reload']); // Final state should have the reloaded value (reload happens after server response). expect(store.state.items, ['reloaded']); }); Bdd(feature) .scenario( 'sendCommandToServer fails: applyServerResponseToState is NOT called.') .given('An action with OptimisticCommand.') .and('sendCommandToServer throws an error.') .when('The action is dispatched.') .then( 'applyServerResponseToState is NOT called (no server response on error).') .and('Rollback happens as usual.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithServerResponseThatFails('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedFailed, isTrue); expect(action.applyServerResponseCalled, isFalse); // State was rolled back. expect(store.state.items, ['initial']); }); Bdd(feature) .scenario( 'OptimisticCommand + Retry: server response is applied after successful retry.') .given('An action with OptimisticCommand and Retry mixins.') .and( 'sendCommandToServer fails 2 times, then succeeds and returns a value.') .when('The action is dispatched.') .then( 'applyServerResponseToState is called with the value from the successful attempt.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithRetryAndServerResponse('new_item', failCount: 2); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.serverResponseApplied, isTrue); // Final state should have the server-confirmed value. expect(store.state.items, ['initial', 'new_item', 'server_confirmed']); // Only one state change for optimistic update (no flickering). expect(action.stateChangesLog.length, 1); expect(action.stateChangesLog[0], ['initial', 'new_item']); }); Bdd(feature) .scenario( 'Server response can be transformed in applyServerResponseToState.') .given('An action with OptimisticCommand.') .and('sendCommandToServer returns a complex object (map).') .and('applyServerResponseToState transforms the response.') .when('The action is dispatched.') .then('The transformed response is applied to state.') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithServerResponseTransformation('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); // State should have the transformed server response. expect(store.state.items, ['new_item (confirmed: 2024-01-01)']); }); Bdd(feature) .scenario( 'Default shouldReload with server response: only server response is applied on success.') .given('An action with OptimisticCommand using default shouldReload.') .and('sendCommandToServer succeeds and returns a value.') .and('reloadFromServer is implemented.') .when('The action is dispatched.') .then('applyServerResponseToState is called.') .and( 'reloadFromServer is NOT called (default shouldReload skips reload on success).') .run((_) async { var store = Store(initialState: AppState(items: ['initial'])); var action = SaveItemActionWithDefaultShouldReloadAndServerResponse('new_item'); await store.dispatchAndWait(action); expect(action.status.isCompletedOk, isTrue); expect(action.serverResponseApplied, isTrue); expect(action.reloadWasCalled, isFalse); // State should have the server-confirmed value (not reloaded). expect(store.state.items, ['initial', 'new_item', 'server_confirmed']); }); } // ----------------------------------------------------------------------------- // State // ----------------------------------------------------------------------------- class AppState { final List items; AppState({required this.items}); AppState copy({List? items}) => AppState(items: items ?? this.items); @override String toString() => 'AppState(items: $items)'; } // ----------------------------------------------------------------------------- // Actions // ----------------------------------------------------------------------------- /// Basic OptimisticCommand action that succeeds. class SaveItemAction extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChanges = []; SaveItemAction(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChanges.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } @override Future reloadFromServer() async { await Future.delayed(const Duration(milliseconds: 10)); return ['reloaded']; } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => true; } /// OptimisticCommand action that always fails sendCommandToServer. class SaveItemActionThatFails extends ReduxAction with OptimisticCommand { final String newItem; int saveAttempts = 0; SaveItemActionThatFails(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { saveAttempts++; await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override Future reloadFromServer() async { return ['reloaded']; } } /// OptimisticCommand action that fails and tracks state changes (for rollback test). class SaveItemActionThatFailsWithStateLog extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionThatFailsWithStateLog(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override Future reloadFromServer() async { return ['reloaded']; } } /// OptimisticCommand action that fails after another action changes the state. /// This tests the conditional rollback logic. class SaveItemActionThatFailsAfterStateChange extends ReduxAction with OptimisticCommand { final String newItem; final Store _store; final List> stateChangesLog = []; bool rollbackOccurred = false; SaveItemActionThatFailsAfterStateChange(this.newItem, this._store); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; // Track if this is a rollback (going back to initial). if (stateChangesLog.isNotEmpty && newItems.length == 1 && newItems[0] == 'initial') { rollbackOccurred = true; } stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Another action changes the state during save. _store.dispatch(ChangeStateAction()); await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override Future reloadFromServer() async { return ['reloaded']; } } /// Action that changes the state (used to simulate concurrent modification). class ChangeStateAction extends ReduxAction { @override AppState reduce() => state.copy(items: ['changed_by_other']); } /// OptimisticCommand action that does NOT implement reloadFromServer. class SaveItemActionWithoutReload extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionWithoutReload(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } // reloadFromServer is intentionally NOT overridden - uses default that throws UnimplementedError. } /// OptimisticCommand action that does NOT implement reloadFromServer and fails. class SaveItemActionWithoutReloadThatFails extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionWithoutReloadThatFails(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } // reloadFromServer is intentionally NOT overridden - uses default that throws UnimplementedError. } /// OptimisticCommand + Retry action without reloadFromServer implementation. class SaveItemActionWithRetryNoReload extends ReduxAction with OptimisticCommand, Retry { final String newItem; final int failCount; int _saveAttemptCount = 0; final List> stateChangesLog = []; SaveItemActionWithRetryNoReload(this.newItem, {required this.failCount}); @override Duration get initialDelay => const Duration(milliseconds: 5); @override int get maxRetries => 10; @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { _saveAttemptCount++; await Future.delayed(const Duration(milliseconds: 5)); if (_saveAttemptCount <= failCount) { throw UserException('Save failed: attempt $_saveAttemptCount'); } } // reloadFromServer is intentionally NOT overridden - uses default that throws UnimplementedError. } /// OptimisticCommand + Retry action that fails [failCount] times then succeeds. class SaveItemActionWithRetry extends ReduxAction with OptimisticCommand, Retry { final String newItem; final int failCount; int _saveAttemptCount = 0; final List> stateChangesLog = []; SaveItemActionWithRetry(this.newItem, {required this.failCount}); @override Duration get initialDelay => const Duration(milliseconds: 5); @override int get maxRetries => 10; @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { _saveAttemptCount++; await Future.delayed(const Duration(milliseconds: 5)); if (_saveAttemptCount <= failCount) { throw UserException('Save failed: attempt $_saveAttemptCount'); } } @override Future reloadFromServer() async { await Future.delayed(const Duration(milliseconds: 5)); return ['reloaded']; } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => true; } /// OptimisticCommand + Retry action that always fails (tests rollback after exhausting retries). class SaveItemActionWithRetryThatAlwaysFails extends ReduxAction with OptimisticCommand, Retry { final String newItem; final List> stateChangesLog = []; SaveItemActionWithRetryThatAlwaysFails(this.newItem); @override Duration get initialDelay => const Duration(milliseconds: 5); @override int get maxRetries => 3; @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 5)); throw const UserException('Save always fails'); } @override Future reloadFromServer() async { return ['reloaded']; } } // ----------------------------------------------------------------------------- // Actions for testing override methods // ----------------------------------------------------------------------------- /// Action that overrides rollbackState to mark the item as failed instead of removing it. class SaveItemActionWithCustomRollback extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; Object? capturedError; SaveItemActionWithCustomRollback(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override AppState? rollbackState({ required Object? initialValue, required Object? optimisticValue, required Object error, }) { capturedError = error; // Instead of restoring initial value, mark the item as failed. final items = optimisticValue as List; final markedItems = items.map((item) => item == newItem ? '$item (FAILED)' : item).toList(); return state.copy(items: markedItems); } // No reload to keep test simple. } /// Action that overrides rollbackState to return null (skip rollback). class SaveItemActionWithRollbackReturningNull extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionWithRollbackReturningNull(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override AppState? rollbackState({ required Object? initialValue, required Object? optimisticValue, required Object error, }) { // Return null to skip rollback. return null; } // No reload to keep test simple. } /// Action that overrides shouldRollback to always rollback (even if state changed). class SaveItemActionWithAlwaysRollback extends ReduxAction with OptimisticCommand { final String newItem; final Store _store; final List> stateChangesLog = []; SaveItemActionWithAlwaysRollback(this.newItem, this._store); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Another action changes the state during save. _store.dispatch(ChangeStateAction()); await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override bool shouldRollback({ required Object? currentValue, required Object? initialValue, required Object? optimisticValue, required Object error, }) { // Always rollback, regardless of whether state changed. return true; } // No reload to keep test simple. } /// Action that overrides shouldRollback to never rollback. class SaveItemActionWithNeverRollback extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionWithNeverRollback(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override bool shouldRollback({ required Object? currentValue, required Object? initialValue, required Object? optimisticValue, required Object error, }) { // Never rollback. return false; } // No reload to keep test simple. } /// Action that overrides shouldRollback based on error type. class SaveItemActionWithConditionalRollback extends ReduxAction with OptimisticCommand { final String newItem; final bool throwNetworkError; final List> stateChangesLog = []; SaveItemActionWithConditionalRollback(this.newItem, {required this.throwNetworkError}); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); if (throwNetworkError) { throw const UserException('Network error'); } else { throw const UserException('Validation error'); } } @override bool shouldRollback({ required Object? currentValue, required Object? initialValue, required Object? optimisticValue, required Object error, }) { // Only rollback for validation errors, not network errors (might retry later). if (error is UserException && error.toString().contains('Network error')) { return false; } return true; } // No reload to keep test simple. } /// Action that overrides shouldReload to skip reload on success. class SaveItemActionWithConditionalReload extends ReduxAction with OptimisticCommand { final String newItem; final bool shouldFail; final List> stateChangesLog = []; bool reloadWasCalled = false; SaveItemActionWithConditionalReload(this.newItem, {required this.shouldFail}); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); if (shouldFail) throw const UserException('Save failed'); } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, }) { // Only reload on error, not on success. return error != null; } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } } /// Action that overrides shouldApplyReload to skip applying if state changed. class SaveItemActionWithConditionalApplyReload extends ReduxAction with OptimisticCommand { final String newItem; final Store _store; final bool changeStateDuringReload; final List> stateChangesLog = []; SaveItemActionWithConditionalApplyReload( this.newItem, this._store, { required this.changeStateDuringReload, }); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } @override bool shouldApplyReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? reloadResult, required Object? error, }) { // Only apply reload if state hasn't changed since we applied our value. return currentValue == lastAppliedValue; } @override Future reloadFromServer() async { if (changeStateDuringReload) { // Simulate another action changing state while we're reloading. _store.dispatch(ChangeStateAction()); await Future.delayed(const Duration(milliseconds: 10)); } return ['reloaded']; } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => true; } /// Action that overrides applyReloadResultToState to transform reload result. class SaveItemActionWithCustomApplyReload extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionWithCustomApplyReload(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } @override Future reloadFromServer() async { // Return a map instead of a list (different shape than expected by applyValueToState). return { 'items': ['server_item1', 'server_item2'], 'count': 2 }; } @override AppState? applyReloadResultToState(AppState state, Object? reloadResult) { // Transform the map result into what we need. final map = reloadResult as Map; final items = (map['items'] as List).cast(); // Add a marker to show we transformed the data. return state.copy(items: [...items, 'TRANSFORMED']); } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => true; } /// Action that overrides applyReloadResultToState to return null (skip applying). class SaveItemActionWithApplyReloadReturningNull extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool reloadWasCalled = false; SaveItemActionWithApplyReloadReturningNull(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } @override AppState? applyReloadResultToState(AppState state, Object? reloadResult) { // Return null to skip applying the reload result. return null; } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => true; } // ----------------------------------------------------------------------------- // Actions for edge case tests // ----------------------------------------------------------------------------- /// Action where sendCommandToServer succeeds but reloadFromServer throws. class SaveItemActionWithReloadThatThrows extends ReduxAction with OptimisticCommand { final String newItem; SaveItemActionWithReloadThatThrows(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Succeeds - no throw. } @override Future reloadFromServer() async { throw const UserException('Reload failed'); } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, // null on success }) => true; } /// Action where both sendCommandToServer and reloadFromServer throw. class SaveItemActionWithBothCommandAndReloadThatThrow extends ReduxAction with OptimisticCommand { final String newItem; SaveItemActionWithBothCommandAndReloadThatThrow(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Command failed'); } @override Future reloadFromServer() async { throw const UserException('Reload failed'); } } /// Action that overrides shouldReload to return false when there's an error. class SaveItemActionWithShouldReloadFalseOnError extends ReduxAction with OptimisticCommand { final String newItem; bool reloadWasCalled = false; SaveItemActionWithShouldReloadFalseOnError(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Command failed'); } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, }) { // Skip reload when there's an error. return error == null; } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } } /// Action that overrides shouldApplyReload to return false when there's an error. class SaveItemActionWithShouldApplyReloadFalseOnError extends ReduxAction with OptimisticCommand { final String newItem; bool reloadWasCalled = false; SaveItemActionWithShouldApplyReloadFalseOnError(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Command failed'); } @override bool shouldApplyReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? reloadResult, required Object? error, }) { // Skip applying reload when there's an error. return error == null; } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } } /// Action that captures lastAppliedValue and rollbackValue on error. class SaveItemActionCaptureLastAppliedOnError extends ReduxAction with OptimisticCommand { final String newItem; Object? capturedLastAppliedValue; Object? capturedRollbackValue; SaveItemActionCaptureLastAppliedOnError(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Command failed'); } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, }) { // Capture the values for testing. capturedLastAppliedValue = lastAppliedValue; capturedRollbackValue = rollbackValue; return false; // Skip reload to simplify test. } } /// Action that captures lastAppliedValue on success. class SaveItemActionCaptureLastAppliedOnSuccess extends ReduxAction with OptimisticCommand { final String newItem; Object? capturedLastAppliedValue; Object? capturedOptimisticValue; Object? capturedRollbackValue; SaveItemActionCaptureLastAppliedOnSuccess(this.newItem); @override Object? optimisticValue() { final value = [...state.items, newItem]; capturedOptimisticValue = value; return value; } @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Succeeds - no throw. } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, }) { // Capture the values for testing. capturedLastAppliedValue = lastAppliedValue; capturedRollbackValue = rollbackValue; return false; // Skip reload to simplify test. } } /// Action with Retry that counts optimisticValue and sendCommandToServer calls. class SaveItemActionWithOptimisticValueCounter extends ReduxAction with OptimisticCommand, // ignore: private_collision_in_mixin_application Retry { final String newItem; final int failCount; int optimisticValueCallCount = 0; int sendCommandCallCount = 0; SaveItemActionWithOptimisticValueCounter(this.newItem, {required this.failCount}); @override Duration get initialDelay => const Duration(milliseconds: 5); @override int get maxRetries => 10; @override Object? optimisticValue() { optimisticValueCallCount++; return [...state.items, newItem]; } @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { sendCommandCallCount++; await Future.delayed(const Duration(milliseconds: 5)); if (sendCommandCallCount <= failCount) { throw UserException('Failed: attempt $sendCommandCallCount'); } } // No reload to keep test simple. } /// Action that checks identity of optimisticValue passed to sendCommandToServer. class SaveItemActionCheckIdentity extends ReduxAction with OptimisticCommand { final String newItem; Object? createdOptimisticValue; Object? receivedValueInSendCommand; SaveItemActionCheckIdentity(this.newItem); @override Object? optimisticValue() { // Create a new list and store reference for identity check. createdOptimisticValue = [...state.items, newItem]; return createdOptimisticValue; } @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { receivedValueInSendCommand = newValue; await Future.delayed(const Duration(milliseconds: 10)); } // No reload to keep test simple. } // ----------------------------------------------------------------------------- // Actions for non-reentrant behavior tests // ----------------------------------------------------------------------------- /// OptimisticCommand action with configurable delay (for testing non-reentrant behavior). class OptimisticCommandSlowAction extends ReduxAction with OptimisticCommand { final String newItem; final int delayMillis; OptimisticCommandSlowAction(this.newItem, {required this.delayMillis}); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(Duration(milliseconds: delayMillis)); } // No reload to keep test simple. } /// OptimisticCommand action that always fails (for testing key release on failure). class OptimisticCommandFailingAction extends ReduxAction with OptimisticCommand { @override Object? optimisticValue() => [...state.items, 'failing_item']; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Intentional failure'); } // No reload to keep test simple. } /// OptimisticCommand action that uses nonReentrantKeyParams to differentiate by itemId. class OptimisticCommandWithParams extends ReduxAction with OptimisticCommand { final String itemId; final String value; final int delayMillis; OptimisticCommandWithParams(this.itemId, this.value, {required this.delayMillis}); @override Object? nonReentrantKeyParams() => itemId; @override Object? optimisticValue() => [...state.items, value]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(Duration(milliseconds: delayMillis)); } // No reload to keep test simple. } /// OptimisticCommand action that uses nonReentrantKeyParams and always fails. class OptimisticCommandWithParamsThatFails extends ReduxAction with OptimisticCommand { final String itemId; OptimisticCommandWithParamsThatFails(this.itemId); @override Object? nonReentrantKeyParams() => itemId; @override Object? optimisticValue() => [...state.items, 'failing_$itemId']; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Intentional failure'); } // No reload to keep test simple. } /// First OptimisticCommand action type that uses a shared non-reentrant key via computeNonReentrantKey. class OptimisticCommandSharedKey1 extends ReduxAction with OptimisticCommand { final String value; final int delayMillis; OptimisticCommandSharedKey1(this.value, {required this.delayMillis}); @override Object computeNonReentrantKey() => 'sharedOptimisticKey'; @override Object? optimisticValue() => [...state.items, value]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(Duration(milliseconds: delayMillis)); } // No reload to keep test simple. } /// Second OptimisticCommand action type that uses the same shared non-reentrant key. class OptimisticCommandSharedKey2 extends ReduxAction with OptimisticCommand { final String value; final int delayMillis; OptimisticCommandSharedKey2(this.value, {required this.delayMillis}); @override Object computeNonReentrantKey() => 'sharedOptimisticKey'; @override Object? optimisticValue() => [...state.items, value]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(Duration(milliseconds: delayMillis)); } // No reload to keep test simple. } /// OptimisticCommand action that also uses NonReentrant (should throw assertion error). class OptimisticCommandWithNonReentrant extends ReduxAction with OptimisticCommand, NonReentrant { final String newItem; OptimisticCommandWithNonReentrant(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } } /// OptimisticCommand action that also uses Throttle (should throw assertion error). class OptimisticCommandWithThrottle extends ReduxAction with OptimisticCommand, Throttle { final String newItem; OptimisticCommandWithThrottle(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); } } /// OptimisticCommand action that also uses Fresh (should throw assertion error). class OptimisticCommandWithFresh extends ReduxAction with OptimisticCommand, Fresh { final String newItem; OptimisticCommandWithFresh(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) => state.copy(items: value as List); @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); return null; } } // ----------------------------------------------------------------------------- // Actions for testing DEFAULT shouldReload behavior (only reload on error) // ----------------------------------------------------------------------------- /// Action that uses default shouldReload (only reload on error) - success case. /// reloadFromServer should NOT be called on success. class SaveItemActionWithDefaultShouldReloadOnSuccess extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool reloadWasCalled = false; SaveItemActionWithDefaultShouldReloadOnSuccess(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); return null; // Success, no error } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } // NOTE: We do NOT override shouldReload - it uses the default (error != null) } /// Action that uses default shouldReload (only reload on error) - failure case. /// reloadFromServer SHOULD be called on failure. class SaveItemActionWithDefaultShouldReloadOnFailure extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool reloadWasCalled = false; SaveItemActionWithDefaultShouldReloadOnFailure(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } // NOTE: We do NOT override shouldReload - it uses the default (error != null) } // ----------------------------------------------------------------------------- // Actions for testing sendCommandToServer returning a value // ----------------------------------------------------------------------------- /// Action where sendCommandToServer returns a server response that is applied. class SaveItemActionWithServerResponse extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool serverResponseApplied = false; Object? receivedServerResponse; SaveItemActionWithServerResponse(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Server returns a confirmed response with additional data return ['initial', newItem, 'server_confirmed']; } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { serverResponseApplied = true; receivedServerResponse = serverResponse; return state.copy(items: serverResponse as List); } // No reload - we just want to test server response } /// Action where sendCommandToServer returns null (no server response to apply). class SaveItemActionWithNullServerResponse extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool applyServerResponseCalled = false; SaveItemActionWithNullServerResponse(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); return null; // No server response } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { applyServerResponseCalled = true; return state.copy(items: serverResponse as List); } } /// Action where applyServerResponseToState returns null (skip applying). class SaveItemActionWithServerResponseReturningNull extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool applyServerResponseCalled = false; SaveItemActionWithServerResponseReturningNull(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); return ['from_server']; // Server returns a value } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { applyServerResponseCalled = true; return null; // Explicitly decide not to apply the server response } } /// Action with both server response and reload to test ordering. class SaveItemActionWithServerResponseAndReload extends ReduxAction with OptimisticCommand { final String newItem; final List operationOrder = []; final List> stateChangesLog = []; SaveItemActionWithServerResponseAndReload(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); return ['server_response']; } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { operationOrder.add('applyServerResponse'); return state.copy(items: serverResponse as List); } @override Future reloadFromServer() async { operationOrder.add('reload'); return ['reloaded']; } @override bool shouldReload({ required Object? currentValue, required Object? lastAppliedValue, required Object? optimisticValue, required Object? rollbackValue, required Object? error, }) => true; // Always reload to test ordering } /// Action that fails in sendCommandToServer - server response should NOT be applied. class SaveItemActionWithServerResponseThatFails extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool applyServerResponseCalled = false; SaveItemActionWithServerResponseThatFails(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); throw const UserException('Save failed'); } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { applyServerResponseCalled = true; return state.copy(items: serverResponse as List); } } /// Action with Retry that tests server response is applied after successful retry. class SaveItemActionWithRetryAndServerResponse extends ReduxAction with OptimisticCommand, Retry { final String newItem; final int failCount; int _saveAttemptCount = 0; final List> stateChangesLog = []; bool serverResponseApplied = false; SaveItemActionWithRetryAndServerResponse(this.newItem, {required this.failCount}); @override Duration get initialDelay => const Duration(milliseconds: 5); @override int get maxRetries => 10; @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { _saveAttemptCount++; await Future.delayed(const Duration(milliseconds: 5)); if (_saveAttemptCount <= failCount) { throw UserException('Save failed: attempt $_saveAttemptCount'); } // Return server response on success return ['initial', newItem, 'server_confirmed']; } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { serverResponseApplied = true; return state.copy(items: serverResponse as List); } } /// Action that tests server response transformation. class SaveItemActionWithServerResponseTransformation extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; SaveItemActionWithServerResponseTransformation(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Server returns a map with more complex data return { 'items': [newItem], 'timestamp': '2024-01-01', 'confirmed': true, }; } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { final map = serverResponse as Map; final items = (map['items'] as List).cast(); // Transform: add confirmed items with timestamp return state.copy( items: items.map((i) => '$i (confirmed: ${map['timestamp']})').toList()); } } /// Action that uses default shouldReload with server response. /// Tests that server response is applied but reload is skipped on success. class SaveItemActionWithDefaultShouldReloadAndServerResponse extends ReduxAction with OptimisticCommand { final String newItem; final List> stateChangesLog = []; bool serverResponseApplied = false; bool reloadWasCalled = false; SaveItemActionWithDefaultShouldReloadAndServerResponse(this.newItem); @override Object? optimisticValue() => [...state.items, newItem]; @override Object? getValueFromState(AppState state) => state.items; @override AppState applyValueToState(AppState state, Object? value) { final newItems = value as List; stateChangesLog.add(newItems); return state.copy(items: newItems); } @override Future sendCommandToServer(Object? newValue) async { await Future.delayed(const Duration(milliseconds: 10)); // Server returns a confirmed response return ['initial', newItem, 'server_confirmed']; } @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { serverResponseApplied = true; return state.copy(items: serverResponse as List); } @override Future reloadFromServer() async { reloadWasCalled = true; return ['reloaded']; } // NOTE: We do NOT override shouldReload - it uses the default (error != null) // So reload will NOT happen on success, but server response will still be applied. } ================================================ FILE: test/optimistic_sync_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart' hide Retry; void main() { var feature = BddFeature('OptimisticSync mixin'); // ========================================================================== // Case 1: Single dispatch applies optimistic update and sends request // ========================================================================== Bdd(feature) .scenario('Single dispatch applies optimistic update and sends request.') .given('An action with the OptimisticSync mixin.') .when('The action is dispatched once.') .then('The optimistic update is applied immediately.') .and('The request is sent to the server.') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); await store.dispatchAndWait(ToggleLikeAction()); expect(store.state.liked, true); expect(requestLog, ['saveValue(true)', 'onFinish()']); }); // ========================================================================== // Case 2: Rapid dispatches apply all optimistic updates but coalesce requests // ========================================================================== Bdd(feature) .scenario( 'Rapid dispatches apply all optimistic updates but coalesce requests.') .given('An action with the OptimisticSync mixin.') .when('The action is dispatched multiple times rapidly.') .then('All optimistic updates are applied immediately.') .and('Only necessary requests are sent (coalesced).') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch rapidly: false -> true -> false -> true store.dispatch(ToggleLikeAction()); // false -> true, sends request await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); store.dispatch(ToggleLikeAction()); // true -> false, locked await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); store.dispatch(ToggleLikeAction()); // false -> true, locked await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Wait for all requests to complete await store.waitAllActions([]); // Final state should be true (last toggle) expect(store.state.liked, true); // Request log: first sends true, no follow-up needed, then onFinish at end expect(requestLog, ['saveValue(true)', 'onFinish()']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 3: Follow-up request sent when state differs after completion // ========================================================================== Bdd(feature) .scenario('Follow-up request sent when state differs after completion.') .given('An action with the OptimisticSync mixin.') .when('The state changes while a request is in flight.') .and('The final state differs from what was sent.') .then('A follow-up request is sent with the new state.') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch: false -> true (sends request) store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Dispatch while locked: true -> false store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); // Wait for all to complete await store.waitAllActions([]); expect(store.state.liked, false); // First request sent true, then follow-up sent false, then onFinish at end expect(requestLog, ['saveValue(true)', 'saveValue(false)', 'onFinish()']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 4: No follow-up when state returns to sent value // ========================================================================== Bdd(feature) .scenario('No follow-up when state returns to sent value.') .given('An action with the OptimisticSync mixin.') .when('The state changes while a request is in flight.') .and('The state returns to the value that was sent.') .then('No follow-up request is sent.') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch: false -> true (sends request) store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); // Dispatch: true -> false store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); // Dispatch: false -> true (back to sent value) store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); await store.waitAllActions([]); expect(store.state.liked, true); // Only one request needed since final state matches sent value, then onFinish expect(requestLog, ['saveValue(true)', 'onFinish()']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 5: Error calls onFinish and keeps optimistic state // ========================================================================== Bdd(feature) .scenario('Error calls onFinish and keeps optimistic state.') .given('An action with the OptimisticSync mixin.') .when('The request fails.') .then('onFinish is called.') .and('The optimistic state remains.') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); shouldFail = true; await store.dispatchAndWait(ToggleLikeAction()); // Optimistic update remains since no reload (onFinish is general-purpose) expect(store.state.liked, true); expect(requestLog, ['saveValue(true)', 'onFinish()']); shouldFail = false; }); // ========================================================================== // Case 6: Different keys can have concurrent requests // ========================================================================== Bdd(feature) .scenario('Different keys can have concurrent requests.') .given('Actions with different optimisticSyncKeyParams.') .when('Both are dispatched concurrently.') .then('Both requests are sent in parallel.') .run((_) async { var store = Store( initialState: AppState(liked: false, items: {'A': false, 'B': false})); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch for item A and B concurrently store.dispatch(ToggleLikeItemAction('A')); store.dispatch(ToggleLikeItemAction('B')); await Future.delayed(const Duration(milliseconds: 10)); // Both optimistic updates applied expect(store.state.items['A'], true); expect(store.state.items['B'], true); await store.waitAllActions([]); // Both requests sent (not blocked by each other) expect(requestLog.contains('saveValue(A, true)'), true); expect(requestLog.contains('saveValue(B, true)'), true); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 7: Same key blocks concurrent requests // ========================================================================== Bdd(feature) .scenario('Same key blocks concurrent requests.') .given('Actions with the same optimisticSyncKeyParams.') .when('Both are dispatched while the first is in flight.') .then('The second does not send a request until the first completes.') .run((_) async { var store = Store( initialState: AppState(liked: false, items: {'A': false})); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch twice for same item store.dispatch(ToggleLikeItemAction('A')); // false -> true await Future.delayed(const Duration(milliseconds: 10)); store.dispatch(ToggleLikeItemAction('A')); // true -> false (locked) await Future.delayed(const Duration(milliseconds: 10)); // Both optimistic updates applied expect(store.state.items['A'], false); // At this point, only one request should have started expect(requestLog, ['saveValue(A, true)']); await store.waitAllActions([]); // After completion, follow-up request sent, then onFinish at end expect(requestLog, ['saveValue(A, true)', 'saveValue(A, false)', 'onFinish(A)']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 8: Lock is released after successful request // ========================================================================== Bdd(feature) .scenario('Lock is released after successful request.') .given('A OptimisticSync action has completed successfully.') .when('The same action is dispatched again.') .then('A new request is sent (not blocked).') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); // First dispatch await store.dispatchAndWait(ToggleLikeAction()); expect(store.state.liked, true); expect(requestLog, ['saveValue(true)', 'onFinish()']); // Second dispatch after completion await store.dispatchAndWait(ToggleLikeAction()); expect(store.state.liked, false); expect(requestLog, ['saveValue(true)', 'onFinish()', 'saveValue(false)', 'onFinish()']); }); // ========================================================================== // Case 9: Lock is released after failed request // ========================================================================== Bdd(feature) .scenario('Lock is released after failed request.') .given('A OptimisticSync action has failed.') .when('The same action is dispatched again.') .then('A new request is sent (not blocked).') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); shouldFail = true; // First dispatch (fails) await store.dispatchAndWait(ToggleLikeAction()); expect(store.state.liked, true); // Optimistic state remains expect(requestLog, ['saveValue(true)', 'onFinish()']); // Second dispatch after failure shouldFail = false; await store.dispatchAndWait(ToggleLikeAction()); expect(store.state.liked, false); expect(requestLog, ['saveValue(true)', 'onFinish()', 'saveValue(false)', 'onFinish()']); }); // ========================================================================== // Case 10: Multiple follow-up requests when state keeps changing // ========================================================================== Bdd(feature) .scenario('Multiple follow-up requests when state keeps changing.') .given('An action with the OptimisticSync mixin.') .when('The state changes during each request.') .then('Follow-up requests are sent until state stabilizes.') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); var requestCount = 0; saveValueCallback = () async { requestCount++; await Future.delayed(const Duration(milliseconds: 50)); // Toggle state during first two requests if (requestCount <= 2) { store.dispatch(ToggleLikeAction()); } }; await store.dispatchAndWait(ToggleLikeAction()); // Should have sent multiple follow-up requests expect(requestLog.length, greaterThan(1)); saveValueCallback = null; }); // ========================================================================== // Case 11: OptimisticSync cannot be combined with NonReentrant // ========================================================================== Bdd(feature) .scenario('OptimisticSync cannot be combined with NonReentrant.') .given('An action that combines OptimisticSync and NonReentrant.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatch(OptimisticSyncWithNonReentrantAction()), throwsA(isA().having( (e) => e.message, 'message', 'The OptimisticSync mixin cannot be combined with the NonReentrant mixin.', )), ); }); // ========================================================================== // Case 12: OptimisticSync cannot be combined with Throttle // ========================================================================== Bdd(feature) .scenario('OptimisticSync cannot be combined with Throttle.') .given('An action that combines OptimisticSync and Throttle.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatch(OptimisticSyncWithThrottleAction()), throwsA(isA().having( (e) => e.message, 'message', 'The OptimisticSync mixin cannot be combined with the Throttle mixin.', )), ); }); // ========================================================================== // Case 13: computeOptimisticSyncKey can be overridden to share keys // ========================================================================== Bdd(feature) .scenario('computeOptimisticSyncKey can be overridden to share keys.') .given( 'Two different action types with the same computeOptimisticSyncKey.') .when('Both are dispatched while the first is in flight.') .then('They share the same lock.') .run((_) async { var store = Store(initialState: AppState(liked: false, count: 0)); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch first action type store.dispatch(SharedKeyAction1()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.count, 1); // Dispatch second action type with same key (should be locked) store.dispatch(SharedKeyAction2()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.count, 2); // Optimistic update applied // At this point, only first request sent expect(requestLog, ['saveValue(sharedKey, 1)']); await store.waitAllActions([]); // Follow-up with current value expect(requestLog, ['saveValue(sharedKey, 1)', 'saveValue(sharedKey, 2)']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 14: State cleanup after store shutdown // ========================================================================== Bdd(feature) .scenario('Coalescing state is cleared on store shutdown.') .given('A OptimisticSync action is in progress.') .when('The store is shut down.') .then('The coalescing state is cleared.') .run((_) async { var store = Store(initialState: AppState(liked: false)); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 50); // Start a request store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); // Shutdown store store.shutdown(); // Wait for old store's action to complete (it continues running even after shutdown) await Future.delayed(const Duration(milliseconds: 100)); // Create new store - should have fresh coalescing state var newStore = Store(initialState: AppState(liked: false)); requestLog.clear(); // Should be able to dispatch without being blocked by old state await newStore.dispatchAndWait(ToggleLikeAction()); expect(newStore.state.liked, true); expect(requestLog, ['saveValue(true)', 'onFinish()']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 15: OptimisticSync cannot be combined with Fresh // ========================================================================== Bdd(feature) .scenario('OptimisticSync cannot be combined with Fresh.') .given('An action that combines OptimisticSync and Fresh.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatch(OptimisticSyncWithFreshAction()), throwsA(isA().having( (e) => e.message, 'message', 'The OptimisticSync mixin cannot be combined with the Fresh mixin.', )), ); }); // ========================================================================== // Case 16: OptimisticSync cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'OptimisticSync cannot be combined with UnlimitedRetryCheckInternet.') .given( 'An action that combines OptimisticSync and UnlimitedRetryCheckInternet.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatch(OptimisticSyncWithUnlimitedRetryCheckInternetAction()), throwsA(isA().having( (e) => e.message, 'message', 'The UnlimitedRetryCheckInternet mixin cannot be combined with the OptimisticSync mixin.', )), ); }); // ========================================================================== // Case 17: OptimisticSync cannot be combined with UnlimitedRetries // ========================================================================== Bdd(feature) .scenario('OptimisticSync cannot be combined with UnlimitedRetries.') .given('An action that combines OptimisticSync and UnlimitedRetries.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatch(OptimisticSyncWithUnlimitedRetriesAction()), throwsA(isA().having( (e) => e.message, 'message', 'The Retry mixin cannot be combined with the OptimisticSync mixin.', )), ); }); // ========================================================================== // Case 19: OptimisticSync cannot be combined with OptimisticSyncWithPush // ========================================================================== // NOTE: This combination now causes a COMPILE-TIME error because the two // mixins define sendValueToServer with different signatures: // - OptimisticSync: sendValueToServer(Object? optimisticValue) // - OptimisticSyncWithPush: sendValueToServer(Object? optimisticValue, int localRevision, int deviceId) // // This is actually BETTER than a runtime assertion error because it // catches the incompatibility at compile time. The test below is skipped // since we cannot even create such a class. // // To verify: try uncommenting OptimisticSyncWithOptimisticSyncWithPushAction // in this file and you'll get a compilation error. // ========================================================================== // Case 21: OptimisticSync cannot be combined with Debounce // ========================================================================== Bdd(feature) .scenario('OptimisticSync cannot be combined with Debounce.') .given('An action that combines OptimisticSync and Debounce.') .when('The action is dispatched.') .then('An assertion error is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatch(OptimisticSyncWithDebounceAction()), throwsA(isA().having( (e) => e.message, 'message', 'The OptimisticSync mixin cannot be combined with the Debounce mixin.', )), ); }); // ========================================================================== // Case 22: Server response is applied when non-null and state is stable // ========================================================================== Bdd(feature) .scenario( 'Server response is applied when sendValueToServer returns a non-null response and state is stable.') .given( 'An action with the OptimisticSync mixin where sendValueToServer returns a non-null response.') .when( 'The action is dispatched once and no other dispatch happens while the request is in flight.') .then('The optimistic update is applied immediately.') .and( 'After the request completes, applyServerResponseToState is applied using the server response.') .and('onFinish is called once after synchronization completes.') .run((_) async { var store = Store(initialState: AppState(liked: false, count: 0)); requestLog.clear(); // Dispatch action that returns server response await store.dispatchAndWait(ServerResponseAction(increment: 10)); // Optimistic update was 10, but server returns 15 (normalized value) expect(store.state.count, 15); expect(requestLog, ['saveValue(10)', 'serverResponse(15)', 'onFinish()']); }); // ========================================================================== // Case 23: Earlier server response does not overwrite newer local optimistic value // ========================================================================== Bdd(feature) .scenario( 'An earlier server response does not overwrite a newer local optimistic value.') .given( 'An action with the OptimisticSync mixin where sendValueToServer returns a non-null response.') .when('The action is dispatched and a request is in flight.') .and( 'The action is dispatched again for the same key while the first request is still in flight.') .then( 'The latest optimistic value remains visible after the second dispatch.') .and( 'The response from the first request is not applied in a way that overwrites the newer optimistic value.') .and( 'If a follow-up request is needed, only the final stabilized result is applied.') .run((_) async { var store = Store(initialState: AppState(liked: false, count: 0)); requestLog.clear(); saveValueDelay = const Duration(milliseconds: 100); // Dispatch first action: optimistic count = 10 store.dispatch(ServerResponseAction(increment: 10)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.count, 10, reason: 'First optimistic update'); // Dispatch second action while first is in flight: optimistic count = 20 store.dispatch(ServerResponseAction(increment: 10)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.count, 20, reason: 'Second optimistic update'); // Wait for all to complete await store.waitAllActions([]); // First request would have returned 15 (server normalized 10 to 15), // but that should NOT be applied because state changed while in flight. // A follow-up request was sent with 20, which server normalizes to 25. expect(store.state.count, 25, reason: 'Final state from follow-up'); // Request log shows: first request, follow-up request, then only final serverResponse applied expect(requestLog, ['saveValue(10)', 'saveValue(20)', 'serverResponse(25)', 'onFinish()']); saveValueDelay = Duration.zero; }); // ========================================================================== // Case 24: Server response is ignored when applyServerResponseToState returns null // ========================================================================== Bdd(feature) .scenario( 'Server response is ignored when applyServerResponseToState returns null.') .given( 'An action with the OptimisticSync mixin where sendValueToServer returns a non-null response.') .when('The action is dispatched and completes successfully.') .then('The optimistic update is applied immediately.') .and('The server response is not applied to the state.') .and('onFinish is still called after synchronization completes.') .run((_) async { var store = Store(initialState: AppState(liked: false, count: 0)); requestLog.clear(); // Dispatch action that ignores server response await store.dispatchAndWait(IgnoreServerResponseAction(increment: 10)); // Optimistic update was 10, server returned 15, but it's ignored expect(store.state.count, 10, reason: 'Server response ignored'); expect(requestLog, ['saveValue(10)', 'onFinish()']); }); // ========================================================================== // Case 25: With multiple follow-ups, only the final server response is applied // ========================================================================== Bdd(feature) .scenario( 'With multiple follow-up requests, only the final non-null server response is applied.') .given( 'An action with the OptimisticSync mixin where sendValueToServer returns a non-null response.') .when( 'The action is dispatched and the state changes during each request, causing multiple follow-up requests.') .then('Multiple requests are sent until the state stabilizes.') .and('Only the final server response is applied to the state.') .and('onFinish is called once after synchronization completes.') .run((_) async { var store = Store(initialState: AppState(liked: false, count: 0)); requestLog.clear(); var requestCount = 0; saveValueCallback = () async { requestCount++; await Future.delayed(const Duration(milliseconds: 50)); // Dispatch again during the first two requests to force follow-ups if (requestCount <= 2) { store.dispatch(ServerResponseAction(increment: 10)); } }; // Initial dispatch: count goes from 0 to 10 await store.dispatchAndWait(ServerResponseAction(increment: 10)); // Should have sent 3 requests (initial + 2 follow-ups) // Only the final server response should be applied // Request chain: 10 -> server returns 15 (not applied, state changed to 20) // 20 -> server returns 25 (not applied, state changed to 30) // 30 -> server returns 35 (applied, state stable) expect(store.state.count, 35, reason: 'Only final server response applied'); // Verify 3 saveValue calls, but only 1 serverResponse applied expect(requestLog.where((e) => e.startsWith('saveValue')).length, 3); expect(requestLog.where((e) => e.startsWith('serverResponse')).length, 1); expect(requestLog.last, 'onFinish()'); saveValueCallback = null; }); // =========================================================================== // Case 26: Bug demonstration WITHOUT revisions // =========================================================================== Bdd(feature) .scenario('BUG: Push can cause missed follow-up.') .given('An action WITHOUT revision tracking.') .when('User taps twice and push arrives between taps.') .then('OptimisticSync incorrectly thinks state is stable.') .note('With push we must use the `OptimisticSyncWithPush` mixin instead.') .run((_) async { var store = Store(initialState: AppState(liked: false)); // Reset shared test controls to avoid bleed-over from previous scenarios. requestLog.clear(); saveValueDelay = Duration.zero; shouldFail = false; saveValueCallback = null; requestCompleter = null; // Use completer to control when request completes final request1Completer = Completer(); requestCompleter = request1Completer; // Tap #1: liked=false -> liked=true (optimistic) store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Tap #1 optimistic update'); expect(requestLog, ['saveValue(true)']); // Tap #2 (while request 1 in flight): liked=true -> liked=false (optimistic) store.dispatch(ToggleLikeAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Tap #2 optimistic update'); // Push arrives (echo of request 1) - overwrites optimistic state! // This simulates a WebSocket push arriving before request completes store.dispatch(SimulatePushAction(liked: true)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Push overwrote optimistic state'); // Request 1 completes request1Completer.complete(); await Future.delayed(const Duration(milliseconds: 50)); // BUG: OptimisticSync sees store=true, sent=true, thinks it's stable! // No follow-up sent, final state is WRONG (user's last tap was false) expect(store.state.liked, true, reason: 'BUG: Final state is wrong (should be false)'); expect(requestLog, ['saveValue(true)', 'onFinish()'], reason: 'BUG: No follow-up request sent'); }); } // ============================================================================= // Test state and helpers // ============================================================================= class AppState { final bool liked; final Map items; final int count; AppState({ required this.liked, this.items = const {}, this.count = 0, }); AppState copy({bool? liked, Map? items, int? count}) => AppState( liked: liked ?? this.liked, items: items ?? this.items, count: count ?? this.count, ); @override String toString() => 'AppState(liked: $liked, items: $items, count: $count)'; } // Test control variables List requestLog = []; Duration saveValueDelay = Duration.zero; bool shouldFail = false; Future Function()? saveValueCallback; // ============================================================================= // Test actions // ============================================================================= /// Basic toggle like action. class ToggleLikeAction extends ReduxAction with OptimisticSync { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async { requestLog.add('saveValue($value)'); if (saveValueCallback != null) { await saveValueCallback!(); } else if (saveValueDelay != Duration.zero) { await Future.delayed(saveValueDelay); } else if (requestCompleter != null) { // Allow tests to hold the request open until they manually complete it. final completer = requestCompleter!; requestCompleter = null; await completer.future; } if (shouldFail) { throw const UserException('Send failed'); } return null; } @override Future onFinish(Object? error) async { requestLog.add('onFinish()'); return null; } } /// Toggle like for a specific item (uses optimisticSyncKeyParams). class ToggleLikeItemAction extends ReduxAction with OptimisticSync { final String itemId; ToggleLikeItemAction(this.itemId); @override Object? optimisticSyncKeyParams() => itemId; @override bool valueToApply() => !(state.items[itemId] ?? false); @override bool getValueFromState(AppState state) => state.items[itemId] ?? false; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) { final newItems = Map.from(state.items); newItems[itemId] = optimisticValueToApply; return state.copy(items: newItems); } @override AppState applyServerResponseToState(state, Object? serverResponse) { final newItems = Map.from(state.items); newItems[itemId] = serverResponse as bool; return state.copy(items: newItems); } @override Future sendValueToServer(Object? value) async { requestLog.add('saveValue($itemId, $value)'); if (saveValueDelay != Duration.zero) { await Future.delayed(saveValueDelay); } if (shouldFail) { throw const UserException('Send failed'); } return null; } @override Future onFinish(Object? error) async { requestLog.add('onFinish($itemId)'); return null; } } /// Action that returns a non-null server response. /// Server "normalizes" the value by adding 5 (e.g., 10 becomes 15). class ServerResponseAction extends ReduxAction with OptimisticSync { final int increment; ServerResponseAction({required this.increment}); @override int valueToApply() => state.count + increment; @override int getValueFromState(AppState state) => state.count; @override AppState applyOptimisticValueToState(state, int optimisticValueToApply) => state.copy(count: optimisticValueToApply); @override AppState? applyServerResponseToState(state, Object serverResponse) { requestLog.add('serverResponse($serverResponse)'); return state.copy(count: serverResponse as int); } @override Future sendValueToServer(Object? value) async { requestLog.add('saveValue($value)'); if (saveValueCallback != null) { await saveValueCallback!(); } else if (saveValueDelay != Duration.zero) { await Future.delayed(saveValueDelay); } // Server "normalizes" the value by adding 5 return (value as int) + 5; } @override Future onFinish(Object? error) async { requestLog.add('onFinish()'); return null; } } /// Action that returns a non-null server response but ignores it. class IgnoreServerResponseAction extends ReduxAction with OptimisticSync { final int increment; IgnoreServerResponseAction({required this.increment}); @override int valueToApply() => state.count + increment; @override int getValueFromState(AppState state) => state.count; @override AppState applyOptimisticValueToState(state, int optimisticValueToApply) => state.copy(count: optimisticValueToApply); @override AppState? applyServerResponseToState(state, Object serverResponse) { // Intentionally return null to ignore the server response return null; } @override Future sendValueToServer(Object? value) async { requestLog.add('saveValue($value)'); // Server returns a value, but we'll ignore it return (value as int) + 5; } @override Future onFinish(Object? error) async { requestLog.add('onFinish()'); return null; } } /// Action with shared key (type 1). class SharedKeyAction1 extends ReduxAction with OptimisticSync { @override Object computeOptimisticSyncKey() => 'sharedKey'; @override int valueToApply() => state.count + 1; @override int getValueFromState(AppState state) => state.count; @override AppState applyOptimisticValueToState(state, int optimisticValueToApply) => state.copy(count: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(count: serverResponse as int); @override Future sendValueToServer(Object? value) async { requestLog.add('saveValue(sharedKey, $value)'); if (saveValueDelay != Duration.zero) { await Future.delayed(saveValueDelay); } return null; } } /// Action with shared key (type 2). class SharedKeyAction2 extends ReduxAction with OptimisticSync { @override Object computeOptimisticSyncKey() => 'sharedKey'; @override int valueToApply() => state.count + 1; @override int getValueFromState(AppState state) => state.count; @override AppState applyOptimisticValueToState(state, int optimisticValueToApply) => state.copy(count: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(count: serverResponse as int); @override Future sendValueToServer(Object? value) async { requestLog.add('saveValue(sharedKey, $value)'); if (saveValueDelay != Duration.zero) { await Future.delayed(saveValueDelay); } return null; } } // ============================================================================= // Incompatible mixin combinations // ============================================================================= class OptimisticSyncWithNonReentrantAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application NonReentrant { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async => null; } class OptimisticSyncWithThrottleAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application Throttle { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async => null; } class OptimisticSyncWithDebounceAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application Debounce { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async => null; } class OptimisticSyncWithFreshAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application Fresh { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async => null; } class OptimisticSyncWithUnlimitedRetryCheckInternetAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application UnlimitedRetryCheckInternet { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async => null; } class OptimisticSyncWithUnlimitedRetriesAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application Retry, // ignore: private_collision_in_mixin_application UnlimitedRetries { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => state.copy(liked: optimisticValueToApply); @override AppState applyServerResponseToState(state, Object? serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer(Object? value) async => null; } // This class is intentionally commented out because combining OptimisticSync // and OptimisticSyncWithPush causes a COMPILE-TIME error due to conflicting // sendValueToServer signatures. See Case 19 comment above. // // class OptimisticSyncWithOptimisticSyncWithPushAction // extends ReduxAction // with // OptimisticSync, // OptimisticSyncWithPush { // @override // bool valueToApply() => !state.liked; // // @override // bool getValueFromState(AppState state) => state.liked; // // @override // AppState applyOptimisticValueToState(state, bool optimisticValueToApply) => // state.copy(liked: optimisticValueToApply); // // @override // AppState applyServerResponseToState(state, Object? serverResponse) => // state.copy(liked: serverResponse as bool); // // @override // Future sendValueToServer( // Object? optimisticValue, // int localRevision, // int deviceId, // ) async => // null; // // @override // int getServerRevisionFromState(Object? key) => -1; // } // ============================================================================= // Push simulation actions // ============================================================================= /// Simulates a push update WITHOUT revision tracking. /// Used to demonstrate the bug. class SimulatePushAction extends ReduxAction { final bool liked; SimulatePushAction({required this.liked}); @override AppState reduce() => state.copy(liked: liked); } Completer? requestCompleter; ================================================ FILE: test/optimistic_sync_with_push_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; /// These tests verify that [OptimisticSyncWithPush] correctly handles /// server-pushed updates (e.g., via WebSockets) when using the revision-based /// synchronization system. /// /// The revision system consists of: /// - [localRevision]: Tracks local user intent (increments on each dispatch) /// - [informServerRevision]: Reports the server's revision from responses/pushes /// /// This ensures that: /// 1. Push updates don't cause incorrect "stable" detection /// 2. Last-write-wins semantics work across devices /// 3. Out-of-order/replay pushes don't regress state void main() { var feature = BddFeature('OptimisticSyncWithPush mixin'); setUp(() { resetTestState(); }); // =========================================================================== // Case 2: Fix WITH revisions - follow-up correctly sent // =========================================================================== Bdd(feature) .scenario('FIX: With revisions, push does not prevent follow-up.') .given('An action WITH revision tracking.') .when('User taps twice and push arrives between taps.') .then( 'OptimisticSyncWithPush correctly sends follow-up based on localRevision.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); // Set up server to return sequential revisions nextServerRevision = 11; // Tap #1: liked=false -> liked=true (optimistic), localRev=1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Tap #1 optimistic update'); // Tap #2 (while request 1 potentially still processing): localRev=2 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Tap #2 optimistic update'); // Wait for all actions to complete await Future.delayed(const Duration(milliseconds: 100)); // With revisions, the follow-up should have been sent because // localRev(2) > sentLocalRev(1) at the time request 1 completed expect(requestLog.where((s) => s.startsWith('sendValue')).length, greaterThanOrEqualTo(2), reason: 'Follow-up should be sent'); }); // =========================================================================== // Case 3: Remote device wins (last write wins) // =========================================================================== Bdd(feature) .scenario('Remote device wins under last-write-wins.') .given('This device taps LIKE and sends request.') .when('Other device sends UNLIKE with newer serverRev via push.') .then('Remote wins, push value is preserved.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; // This device taps LIKE: localRev=1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Other device sets UNLIKE with newer serverRev=12 (via push) store.dispatch(SimulatePushWithRevisionAction(liked: false, serverRev: 12)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Push from other device applied'); expect(store.state.serverRevision, 12); // Wait for our request to complete await Future.delayed(const Duration(milliseconds: 100)); // The push's value should be preserved because it had a newer serverRev // and the server response (serverRev=11) is stale expect(store.state.serverRevision, 12, reason: 'Push serverRev preserved'); }); // =========================================================================== // Case 4: Local wins over older remote push // =========================================================================== Bdd(feature) .scenario('Local wins when remote push is older.') .given('This device taps LIKE and sends request.') .when('Request completes, then older push arrives.') .then('Local wins, stale push is ignored.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 15; // This device taps LIKE: localRev=1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 100)); expect(store.state.liked, true); expect(store.state.serverRevision, 15); // Old push arrives (serverRev=12 < 15) - should be IGNORED store.dispatch(SimulatePushWithRevisionAction(liked: false, serverRev: 12)); await Future.delayed(const Duration(milliseconds: 10)); // State should NOT change (push was stale) expect(store.state.liked, true, reason: 'Stale push ignored, local wins'); expect(store.state.serverRevision, 15, reason: 'ServerRev unchanged'); }); // =========================================================================== // Case 5: Out-of-order / replay safety // =========================================================================== Bdd(feature) .scenario('Out-of-order pushes are ignored (replay safety).') .given('Client has serverRev=20, liked=true.') .when('Reconnect/replay delivers older pushes.') .then('Older pushes are ignored, only newer applied.') .run((_) async { var store = Store( initialState: AppState(liked: true, serverRevision: 20)); // Old pushes arrive (replay from reconnect) - should be IGNORED store.dispatch(SimulatePushWithRevisionAction(liked: false, serverRev: 18)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'serverRev=18 < 20, ignored'); expect(store.state.serverRevision, 20); store.dispatch(SimulatePushWithRevisionAction(liked: false, serverRev: 19)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'serverRev=19 < 20, ignored'); expect(store.state.serverRevision, 20); // New push arrives (serverRev=21) - should be APPLIED store.dispatch(SimulatePushWithRevisionAction(liked: false, serverRev: 21)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'serverRev=21 > 20, applied'); expect(store.state.serverRevision, 21); }); // =========================================================================== // Case 6: Stale response is not applied when push is newer // =========================================================================== Bdd(feature) .scenario('Stale server response is not applied to state.') .given('Request is sent.') .when('Push with newer serverRev arrives before response.') .then('Response is ignored (stale), push value preserved.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; // Tap: false -> true, localRev=1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 100)); // Now the request has completed with serverRev=11 expect(store.state.liked, true); expect(store.state.serverRevision, 11); // Push arrives with newer serverRev - should be applied store.dispatch(SimulatePushWithRevisionAction(liked: false, serverRev: 15)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Push with newer serverRev applied'); expect(store.state.serverRevision, 15, reason: 'Push serverRev applied (15 > 11)'); // Now a stale push arrives (serverRev=12 < 15) - should be IGNORED store.dispatch(SimulatePushWithRevisionAction(liked: true, serverRev: 12)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Stale push ignored'); expect(store.state.serverRevision, 15, reason: 'ServerRev unchanged'); }); // =========================================================================== // Case 7: Throws error if informServerRevision() is not called // =========================================================================== Bdd(feature) .scenario('Throws error if informServerRevision() is not called.') .given('An action that does not call informServerRevision().') .when('sendValueToServer completes successfully.') .then('A StateError is thrown.') .run((_) async { var store = Store(initialState: AppState(liked: false)); expect( () => store.dispatchAndWait(ToggleLikeActionNoRevisions()), throwsA(isA().having( (e) => e.message, 'message', contains('informServerRevision()'), )), ); }); // =========================================================================== // Case 8: DateTime-based server revision // =========================================================================== Bdd(feature) .scenario('DateTime-based server revision works correctly.') .given('Server uses DateTime for revisions.') .when('Push arrives with DateTime-based serverRev.') .then('Ordering works correctly.') .run((_) async { final oldTime = DateTime(2024, 1, 1, 12, 0, 0); final newTime = DateTime(2024, 1, 1, 12, 0, 1); var store = Store( initialState: AppState( liked: false, serverRevision: oldTime.millisecondsSinceEpoch)); // Push with older DateTime - should be ignored store.dispatch(SimulatePushWithRevisionAction( liked: true, serverRev: oldTime.subtract(const Duration(seconds: 1)).millisecondsSinceEpoch, )); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Older DateTime ignored'); // Push with newer DateTime - should be applied store.dispatch(SimulatePushWithRevisionAction( liked: true, serverRev: newTime.millisecondsSinceEpoch, )); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Newer DateTime applied'); }); // =========================================================================== // Case 9: Multiple rapid taps coalesce correctly with revisions // =========================================================================== Bdd(feature) .scenario('Multiple rapid taps coalesce correctly with revisions.') .given('User taps rapidly multiple times.') .when('Requests complete with revisions.') .then('Final state reflects optimistic updates, requests are coalesced.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; requestDelay = const Duration(milliseconds: 50); // Rapid taps: false -> true -> false -> true -> false -> true for (var i = 0; i < 5; i++) { store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 5)); } // Optimistic state after 5 toggles from false should be true // (odd number of toggles inverts the initial state) expect(store.state.liked, true, reason: '5 toggles from false ends at true (optimistic)'); // Wait for all to complete await Future.delayed(const Duration(milliseconds: 500)); // Should have at least 1 request (coalescing may occur) final sendCount = requestLog.where((s) => s.startsWith('sendValue')).length; expect(sendCount, greaterThanOrEqualTo(1)); // Verify onFinish was called expect(requestLog.last, 'onFinish()'); requestDelay = Duration.zero; }); // =========================================================================== // Case 10: localRevision increments correctly across dispatches // =========================================================================== Bdd(feature) .scenario('localRevision increments correctly across dispatches.') .given('Multiple dispatches occur.') .when('Each dispatch calls localRevision.') .then('First dispatch gets localRev=1, follow-up gets localRev=2.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; requestDelay = const Duration(milliseconds: 50); // Dispatch 1: localRev should be 1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); // Dispatch 2 (while 1 is in flight): this will increment localRev to 2 // but won't send a request yet (locked) store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); // Wait for request 1 to complete and follow-up to be sent await Future.delayed(const Duration(milliseconds: 200)); // Check that first request had localRev=1 expect(requestLog[0], contains('localRev=1'), reason: 'First request has localRev=1'); // If follow-up was sent (because state changed), it should have localRev=2 final sendValueLogs = requestLog.where((s) => s.startsWith('sendValue')).toList(); if (sendValueLogs.length > 1) { expect(sendValueLogs[1], contains('localRev=2'), reason: 'Follow-up has localRev=2'); } requestDelay = Duration.zero; }); // =========================================================================== // Case 11: Push during follow-up is handled correctly // =========================================================================== Bdd(feature) .scenario('Push during follow-up request is handled correctly.') .given('Request completes and follow-up is being sent.') .when('Push arrives during follow-up.') .then('System remains consistent.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; requestDelay = const Duration(milliseconds: 50); // Tap 1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); // Tap 2 (triggers follow-up later) store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); // Push arrives store.dispatch(SimulatePushWithRevisionAction(liked: true, serverRev: 15)); await Future.delayed(const Duration(milliseconds: 10)); // Wait for everything to settle await Future.delayed(const Duration(milliseconds: 200)); // System should be in a consistent state expect(store.state.serverRevision, greaterThanOrEqualTo(11)); expect(requestLog.last, 'onFinish()'); requestDelay = Duration.zero; }); // =========================================================================== // Self-echo push is handled correctly: follow-up still sends latest intent // =========================================================================== Bdd(feature) .scenario( 'Self-echo push is handled correctly: follow-up sends latest intent.') .given('Action with requests in flight and user taps again.') .when('Self-echo push arrives before request completes.') .then('Follow-up sends the latest local intent, not the echoed value.') .note('Self-echo is detected by matching deviceId and stale localRevision.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; // Use completer to precisely control when Request 1 completes final request1Completer = Completer(); requestCompleter = request1Completer; // Tap #1: liked=false -> liked=true (optimistic), localRev=1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Tap #1 optimistic update'); expect(requestLog, ['sendValue(true, localRev=1)']); // Tap #2 (while request 1 in flight): liked=true -> liked=false (optimistic), localRev=2 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Tap #2 optimistic update (user wants false)'); // Self-echo push arrives (echo of Request 1) // Using the same deviceId as the current device and localRevision=1 (stale) // This simulates the server echoing back the first request's value store.dispatch(SimulatePushWithRevisionAction( liked: true, serverRev: 11, pushLocalRevision: 1, // Matches request 1's localRevision pushDeviceId: OptimisticSyncWithPush.deviceId(), // Same device = self-echo )); await Future.delayed(const Duration(milliseconds: 10)); // Self-echo with stale localRevision should NOT apply to state expect(store.state.liked, false, reason: 'Self-echo with stale localRev should not apply'); // Request 1 completes request1Completer.complete(); await Future.delayed(const Duration(milliseconds: 50)); // Follow-up should be sent because localRev advanced and isPush=false expect(requestLog.length, greaterThanOrEqualTo(2), reason: 'Follow-up was sent (revision check passed)'); // Check what the follow-up sent final followUpLog = requestLog.where((s) => s.startsWith('sendValue')).toList(); if (followUpLog.length >= 2) { // Should send user's last intent (false) expect(followUpLog[1], 'sendValue(false, localRev=2)', reason: 'Follow-up should send latest local intent (false)'); } // Wait for everything to complete await Future.delayed(const Duration(milliseconds: 100)); // Final state should match user's last tap (false) expect(store.state.liked, false, reason: 'Final state should be false (user\'s last tap)'); }); // =========================================================================== // Follow-up is based on localRevision, not value comparison // =========================================================================== Bdd(feature) .scenario( 'Follow-up is sent based on localRevision even when value matches sent value.') .given('An action with requests in flight.') .when( 'User toggles multiple times, ending at the same value that was sent.') .then( 'Follow-up IS sent because localRevision advanced (revision-based, not value-based).') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; // Control when Request 1 completes. final request1Completer = Completer(); requestCompleter = request1Completer; // Tap #1: liked=false -> liked=true, localRev=1 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Tap #1 optimistic'); expect(requestLog, ['sendValue(true, localRev=1)']); // Tap #2 (while request 1 in flight): liked=true -> liked=false, localRev=2 store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false, reason: 'Tap #2 optimistic'); // Tap #3 (still while request 1 in flight): liked=false -> liked=true, localRev=3 // Final value returns to the SAME VALUE that was sent in Request 1. store.dispatch(ToggleLikeActionWithRevisions()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true, reason: 'Tap #3 back to sent value'); // Request 1 completes. request1Completer.complete(); await Future.delayed(const Duration(milliseconds: 100)); final sendValueLogs = requestLog.where((s) => s.startsWith('sendValue')).toList(); // With revision-based tracking, a follow-up IS sent because localRev(3) > sentLocalRev(1), // even though the final value (true) equals the sent value (true). // The follow-up sends true with localRev=3. expect(sendValueLogs.length, greaterThanOrEqualTo(2), reason: 'Follow-up is sent because localRevision advanced (revision-based tracking)'); // The follow-up should send the current value (true) with localRev=3 if (sendValueLogs.length >= 2) { expect(sendValueLogs[1], 'sendValue(true, localRev=3)', reason: 'Follow-up sends current value with updated localRevision'); } }); } // ============================================================================= // Test state // ============================================================================= class AppState { final bool liked; final Map items; final int serverRevision; final Map serverRevisions; AppState({ required this.liked, this.items = const {}, this.serverRevision = 0, this.serverRevisions = const {}, }); AppState copy({ bool? liked, Map? items, int? serverRevision, Map? serverRevisions, }) => AppState( liked: liked ?? this.liked, items: items ?? this.items, serverRevision: serverRevision ?? this.serverRevision, serverRevisions: serverRevisions ?? this.serverRevisions, ); @override String toString() => 'AppState(liked: $liked, serverRev: $serverRevision, items: $items)'; } // ============================================================================= // Test control variables // ============================================================================= List requestLog = []; Completer? requestCompleter; int nextServerRevision = 1; Duration requestDelay = Duration.zero; void resetTestState() { requestLog = []; requestCompleter = null; nextServerRevision = 1; requestDelay = Duration.zero; } // ============================================================================= // Action that does NOT call informServerRevision() (to test error detection) // ============================================================================= class ToggleLikeActionNoRevisions extends ReduxAction with OptimisticSyncWithPush { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValue) => state.copy(liked: optimisticValue); @override AppState? applyServerResponseToState(state, Object serverResponse) => state.copy(liked: serverResponse as bool); @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { requestLog.add('sendValue($optimisticValue)'); // Wait for completer if provided if (requestCompleter != null) { await requestCompleter!.future; } else if (requestDelay != Duration.zero) { await Future.delayed(requestDelay); } // Intentionally NOT calling informServerRevision() to test error detection return optimisticValue; } @override Future onFinish(Object? error) async { requestLog.add('onFinish()'); return null; } @override int getServerRevisionFromState(Object? key) { // Use the simple serverRevision field for consistency return state.serverRevision; } } // ============================================================================= // Actions WITH revision tracking (the fix) // ============================================================================= class ToggleLikeActionWithRevisions extends ReduxAction with OptimisticSyncWithPush { int _serverRevFromResponse = 0; @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(state, bool optimisticValue) => state.copy(liked: optimisticValue); @override AppState? applyServerResponseToState(state, Object serverResponse) { return state.copy( liked: serverResponse as bool, serverRevision: _serverRevFromResponse, ); } @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { // localRevision is now passed as a parameter (the CURRENT revision value). // This may differ from what was captured in valueToApply() when this is a // follow-up request (other dispatches may have incremented the revision). requestLog.add('sendValue($optimisticValue, localRev=$localRevision)'); // Wait for completer if provided if (requestCompleter != null) { await requestCompleter!.future; requestCompleter = null; // Reset after use } else if (requestDelay != Duration.zero) { await Future.delayed(requestDelay); } // Get and increment server revision _serverRevFromResponse = nextServerRevision++; informServerRevision(_serverRevFromResponse); return optimisticValue; } @override Future onFinish(Object? error) async { requestLog.add('onFinish()'); return null; } @override int getServerRevisionFromState(Object? key) { // Use the simple serverRevision field (same as SimulatePushWithRevisionAction) return state.serverRevision; } } /// Simulates a push update WITH revision tracking using the ServerPush mixin. /// Only applies if serverRev > current stored serverRev. class SimulatePushWithRevisionAction extends ReduxAction with ServerPush { final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; SimulatePushWithRevisionAction({ required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; // Default to a different deviceId @override Type associatedAction() => ToggleLikeActionWithRevisions; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppState? applyServerPushToState( AppState state, Object? key, int serverRevision) { return state.copy(liked: liked, serverRevision: serverRevision); } @override int getServerRevisionFromState(Object? key) { return state.serverRevision; } } ================================================ FILE: test/persistence_test.dart ================================================ import 'dart:async'; import "package:async_redux/async_redux.dart"; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { // // These tests should probably use a mocked time, but they use the real one. // For this reason it may be necessary to use a multiplier (4 in this case // to account for timing errors. Duration duration(int value) => Duration(milliseconds: value * 4); late MyPersistor persistor; late LocalDb localDb; Future setupPersistorAndLocalDb({ Duration? throttle, Duration? saveDuration, }) async { persistor = MyPersistor(throttle: throttle, saveDuration: saveDuration); await persistor.init(); await persistor.deleteState(); localDb = persistor.localDb; } Future> createStoreTester() async { // var initialState = await persistor.readState(); if (initialState == null) { initialState = AppState.initialState(); await persistor.saveInitialState(initialState); } var store = Store( initialState: initialState, persistor: persistor, ); return StoreTester.from(store); } void printResults(List results) => print("-\nRESULTS:\n${results.join("\n")}\n-"); test('Create some simple state and persist, without throttle.', () async { // await setupPersistorAndLocalDb(); var storeTester = await createStoreTester(); expect(storeTester.state.name, "John"); expect(await storeTester.store.readStateFromPersistence(), storeTester.state); storeTester.dispatch(ChangeNameAction("Mary")); TestInfo info1 = await (storeTester.waitAllGetLast([ChangeNameAction])); expect(localDb.get(db: "main", id: Id("name")), "Mary"); expect(await storeTester.store.readStateFromPersistence(), info1.state); storeTester.dispatch(ChangeNameAction("Steve")); TestInfo info2 = await (storeTester.waitAllGetLast([ChangeNameAction])); expect(localDb.get(db: "main", id: Id("name")), "Steve"); expect(await storeTester.store.readStateFromPersistence(), info2.state); }); test('Create some simple state and persist, with a 1 second throttle.', () async { // await setupPersistorAndLocalDb(throttle: const Duration(seconds: 1)); var storeTester = await createStoreTester(); expect(storeTester.state.name, "John"); expect(await storeTester.store.readStateFromPersistence(), storeTester.state); // 1) The state is changed, but the persisted AppState is not. storeTester.dispatch(ChangeNameAction("Mary")); TestInfo info1 = await (storeTester.waitAllGetLast([ChangeNameAction])); expect(localDb.get(db: "main", id: Id("name")), "John"); expect(info1.state!.name, "Mary"); expect(await storeTester.store.readStateFromPersistence(), isNot(info1.state)); // 2) The state is changed, but the persisted AppState is not. storeTester.dispatch(ChangeNameAction("Steve")); TestInfo info2 = await (storeTester.waitAllGetLast([ChangeNameAction])); expect(localDb.get(db: "main", id: Id("name")), "John"); expect(info2.state!.name, "Steve"); expect(await storeTester.store.readStateFromPersistence(), isNot(info2.state)); // 3) The state is changed, but the persisted AppState is not. storeTester.dispatch(ChangeNameAction("Eve")); TestInfo info3 = await (storeTester.waitAllGetLast([ChangeNameAction])); expect(localDb.get(db: "main", id: Id("name")), "John"); expect(info3.state!.name, "Eve"); expect(await storeTester.store.readStateFromPersistence(), isNot(info3.state)); // 4) Now lets wait until the save is done. await Future.delayed(duration(1500)); expect(localDb.get(db: "main", id: Id("name")), "Eve"); expect(await storeTester.store.readStateFromPersistence(), storeTester.state); }); test( "There is no throttle. " "The state is changed each 40 milliseconds. " "Here we test that the initial state is persisted, " "and then that the state and the persistence change together.", () async { // List results = []; await setupPersistorAndLocalDb(throttle: null); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(40), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 8) { timer.cancel(); completer.complete(); } }); await completer.future; printResults(results); expect( results.join(), "(state:John, db: John)" "(state:0, db: 0)" "(state:1, db: 1)" "(state:2, db: 2)" "(state:3, db: 3)" "(state:4, db: 4)" "(state:5, db: 5)" "(state:6, db: 6)" "(state:7, db: 7)"); }); test( "Pausing then resuming: " "There is no throttle. " "The state is changed each 40 milliseconds. " "We pause the persistor at the 3rd change, and resume it at the 6th. " "Here we test that the initial state is persisted, " "and then that the state and the persistence change together.", () async { // List results = []; await setupPersistorAndLocalDb(throttle: null); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(40), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 8) { timer.cancel(); completer.complete(); } if (count == 3) storeTester.store.pausePersistor(); if (count == 6) storeTester.store.resumePersistor(); }); await completer.future; printResults(results); expect( results.join(), "(state:John, db: John)" "(state:0, db: 0)" "(state:1, db: 1)" "(state:2, db: 2)" // PAUSE here. "(state:3, db: 2)" "(state:4, db: 2)" "(state:5, db: 2)" // RESUME here. "(state:6, db: 6)" "(state:7, db: 7)"); }); // test( "The throttle period is 215 milliseconds. " "The state is changed each 60 milliseconds (at 0, 60, 120, 180, 240 etc). " "Here we test that the initial state is persisted, " "and then that the state and the persistence occur when they should.", () async { // List results = []; await setupPersistorAndLocalDb(throttle: duration(215)); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(60), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 15) { timer.cancel(); completer.complete(); } }); await completer.future; printResults(results); expect( results.join(), "(state:John, db: John)" // It starts with state and db in the initial state: John. "(state:0, db: John)" // Changed state in 60 millis. "(state:1, db: John)" // Changed state in 120 millis. "(state:2, db: John)" // Changed state in 180 millis. "(state:3, db: 2)" // Changed state in 240 millis. Saved db em 215 millis. "(state:4, db: 2)" // Changed state in 300 millis. "(state:5, db: 2)" // Changed state in 360 millis. "(state:6, db: 2)" // Changed state in 420 millis. "(state:7, db: 6)" // Changed state in 480 millis. Saved db em 430 millis. "(state:8, db: 6)" // Changed state in 540 millis. "(state:9, db: 6)" // Changed state in 600 millis. "(state:10, db: 9)" // Changed state in 660 millis. Saved db em 645 millis. "(state:11, db: 9)" // Changed state in 720 millis. "(state:12, db: 9)" // Changed state in 780 millis. "(state:13, db: 9)" // Changed state in 840 millis. "(state:14, db: 13)"); // Changed state in 900 millis. Saved db em 860 millis. }); test( "Pausing then resuming: " "The throttle period is 215 milliseconds. " "The state is changed each 60 milliseconds (at 0, 60, 120, 180, 240 etc). " "We pause the persistor at the 5th change, and resume it at the 12th. " "Here we test that the initial state is persisted, " "and then that the state and the persistence occur when they should.", () async { // List results = []; await setupPersistorAndLocalDb(throttle: duration(215)); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(60), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 15) { timer.cancel(); completer.complete(); } if (count == 5) storeTester.store.pausePersistor(); if (count == 12) storeTester.store.resumePersistor(); }); await completer.future; printResults(results); expect( results.join('\n'), "(state:John, db: John)\n" // It starts with state and db in the initial state: John. "(state:0, db: John)\n" // Changed state in 60 millis. "(state:1, db: John)\n" // Changed state in 120 millis. "(state:2, db: John)\n" // Changed state in 180 millis. "(state:3, db: 2)\n" // Changed state in 240 millis. Saved db em 215 millis. "(state:4, db: 4)\n" // Changed state in 300 millis. PERSIST AND PAUSE here. "(state:5, db: 4)\n" // Changed state in 360 millis. "(state:6, db: 4)\n" // Changed state in 420 millis. "(state:7, db: 4)\n" // Changed state in 480 millis. Saved db em 430 millis. "(state:8, db: 4)\n" // Changed state in 540 millis. "(state:9, db: 4)\n" // Changed state in 600 millis. "(state:10, db: 4)\n" // Changed state in 660 millis. Saved db em 645 millis. "(state:11, db: 4)\n" // Changed state in 720 millis. RESUME here. "(state:12, db: 11)\n" // Changed state in 780 millis. "(state:13, db: 11)\n" // Changed state in 840 millis. "(state:14, db: 11)\n"); // Changed state in 900 millis. Saved db em 860 millis. }, skip: 'Requires precise timing', ); test( "Persisting and pausing, then resuming: " "The throttle period is 215 milliseconds. " "The state is changed each 60 milliseconds (at 0, 60, 120, 180, 240 etc). " "We pause the persistor at the 5th change, and resume it at the 12th. " "Here we test that the initial state is persisted, " "and then that the state and the persistence occur when they should.", () async { // List results = []; await setupPersistorAndLocalDb(throttle: duration(215)); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(60), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 15) { timer.cancel(); completer.complete(); } if (count == 5) storeTester.store.persistAndPausePersistor(); if (count == 12) storeTester.store.resumePersistor(); }); await completer.future; printResults(results); // Expected: ... te:5, db: 2)(state:6 ... // Actual: ... te:5, db: 4)(state:6 ... expect( results.join('\n'), "(state:John, db: John)\n" // It starts with state and db in the initial state: John. "(state:0, db: John)\n" // Changed state in 60 millis. "(state:1, db: John)\n" // Changed state in 120 millis. "(state:2, db: John)\n" // Changed state in 180 millis. "(state:3, db: 2)\n" // Changed state in 240 millis. Saved db em 215 millis. "(state:4, db: 2)\n" // Changed state in 300 millis. PAUSE here. "(state:5, db: 2)\n" // Changed state in 360 millis. "(state:6, db: 2)\n" // Changed state in 420 millis. "(state:7, db: 2)\n" // Changed state in 480 millis. Saved db em 430 millis. "(state:8, db: 2)\n" // Changed state in 540 millis. "(state:9, db: 2)\n" // Changed state in 600 millis. "(state:10, db: 2)\n" // Changed state in 660 millis. Saved db em 645 millis. "(state:11, db: 2)\n" // Changed state in 720 millis. RESUME here. "(state:12, db: 11)\n" // Changed state in 780 millis. "(state:13, db: 11)\n" // Changed state in 840 millis. "(state:14, db: 11)\n"); // Changed state in 900 millis. Saved db em 860 millis. }, skip: 'Requires precise timing', ); test( "There is no throttle. " "Each save takes 430 milliseconds. " "The state is changed each 120 milliseconds. " "Here we test that the initial state is persisted, " "and then that the state and the persistence occur when they should.", () async { // List results = []; await setupPersistorAndLocalDb( throttle: null, saveDuration: duration(430), ); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(120), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 16) { timer.cancel(); completer.complete(); } }); await completer.future; printResults(results); expect( results.join(), "(state:John, db: John)" // It starts with state and db in the initial state: John. "(state:0, db: John)" // Changed the state in 120 millis. Started saving state 0 (will finish: 120+430=550 millis). "(state:1, db: John)" // Changed the state in 240 millis. "(state:2, db: John)" // Changed state in 360 millis. "(state:3, db: John)" // Changed state in 480 millis. Started saving state 3 in 275 millis (will finish: 550+430=980 millis). "(state:4, db: 0)" // Changed state in 600 millis. "(state:5, db: 0)" // Changed state in 720 millis. "(state:6, db: 0)" // Changed state in 840 millis. "(state:7, db: 0)" // Changed state in 960 millis. Started saving state 7 in 490 millis (will finish: 980+430=1410 millis). "(state:8, db: 3)" // Changed state in 1080 millis. "(state:9, db: 3)" // Changed state in 1200 millis. "(state:10, db: 3)" // Changed state in 1320 millis. Started saving state 10 in 705 millis (will finish: 1410+430=1840 millis). "(state:11, db: 7)" // Changed state in 1440 millis. "(state:12, db: 7)" // Changed state in 1560 millis. "(state:13, db: 7)" // Changed state in 1680 millis. "(state:14, db: 7)" // Changed state in 1800 millis. "(state:15, db: 10)"); // Changed state in 1920 millis. Started saving state 15 in 920 millis (will finish: 1840+430 millis). }); test( "Pausing then resuming: " "There is no throttle. " "Each save takes 430 milliseconds. " "The state is changed each 120 milliseconds. " "We pause the persistor at 600 millis (state: 4), and resume it at 1440 millis (state: 11). " "Here we test that the initial state is persisted, " "and then that the state and the persistence occur when they should.", () async { // List results = []; await setupPersistorAndLocalDb( throttle: null, saveDuration: duration(430), ); var storeTester = await createStoreTester(); String result = writeStateAndDb(storeTester, localDb); results.add(result); int count = 0; Completer completer = Completer(); Timer.periodic(duration(120), (timer) { storeTester.dispatch(ChangeNameAction(count.toString())); String result = writeStateAndDb(storeTester, localDb); results.add(result); count++; if (count == 16) { timer.cancel(); completer.complete(); } if (count == 5) storeTester.store.pausePersistor(); if (count == 12) storeTester.store.resumePersistor(); }); await completer.future; printResults(results); expect( results.join(), "(state:John, db: John)" // It starts with state and db in the initial state: John. "(state:0, db: John)" // Changed the state in 120 millis. Started saving state 0 (will finish: 120+430=550 millis). "(state:1, db: John)" // Changed the state in 240 millis. "(state:2, db: John)" // Changed state in 360 millis. "(state:3, db: John)" // Changed state in 480 millis. Started saving state 3 in 275 millis (will finish: 550+430=980 millis). "(state:4, db: 0)" // Changed state in 600 millis. PAUSED here. "(state:5, db: 0)" // Changed state in 720 millis. "(state:6, db: 0)" // Changed state in 840 millis. "(state:7, db: 0)" // Changed state in 960 millis. Does NOT save, because it's paused. "(state:8, db: 3)" // Changed state in 1080 millis. Changed to 3, because previous save finished. "(state:9, db: 3)" // Changed state in 1200 millis. "(state:10, db: 3)" // Changed state in 1320 millis. Started saving state 10 in 705 millis (will finish: 1410+430=1840 millis). "(state:11, db: 3)" // Changed state in 1440 millis. RESUMED here. Will start saving 11. "(state:12, db: 3)" // Changed state in 1560 millis. "(state:13, db: 3)" // Changed state in 1680 millis. "(state:14, db: 3)" // Changed state in 1800 millis. "(state:15, db: 11)"); // Changed state in 1920 millis. Changed to 11, because previous save finished. }); test( "There is a 300 millis throttle. " "A first state change happens. A save starts immediately." "A second state change happens 100 millis after the first. " "No other state changes happen. " "A second save will happen at 300 millis. " "This second save is necessary to save the second state change.", () async { // List results = []; await setupPersistorAndLocalDb( throttle: duration(300), saveDuration: null, ); var storeTester = await createStoreTester(); /// Discard the time waiting for the saving of the initial state. await Future.delayed(duration(300)); // At 0 millis: (state:John, db: John) results.add(writeStateAndDb(storeTester, localDb)); // At 0 millis the state is changed and saved: (state:1st, db: 1st) storeTester.dispatch(ChangeNameAction("1st")); results.add(writeStateAndDb(storeTester, localDb)); // At 100 millis the state is initially unchanged (state:1st, db: 1st) await Future.delayed(duration(100)); results.add(writeStateAndDb(storeTester, localDb)); // At 100 millis the state is changed and saved: (state:2nd, db: 1st) storeTester.dispatch(ChangeNameAction("2nd")); results.add(writeStateAndDb(storeTester, localDb)); // At 200 millis the state is unchanged: (state:2nd, db: 1st) await Future.delayed(duration(100)); results.add(writeStateAndDb(storeTester, localDb)); // Right before 300 millis the state is unchanged: (state:2nd, db: 1st) await Future.delayed(duration(80)); results.add(writeStateAndDb(storeTester, localDb)); // Right after 300 millis the state is saved: (state:2nd, db: 2nd) await Future.delayed(duration(40)); results.add(writeStateAndDb(storeTester, localDb)); printResults(results); expect( results.join(), "(state:John, db: John)" "(state:1st, db: 1st)" "(state:1st, db: 1st)" "(state:2nd, db: 1st)" "(state:2nd, db: 1st)" "(state:2nd, db: 1st)" "(state:2nd, db: 2nd)"); }); test( "There is a 300 save duration, and no throttle. " "A first state change happens. A save starts immediately." "A second state change happens 100 millis after the first. " "No other state changes happen. " "A second save will happen at 300 millis. " "This second save is necessary to save the second state change.", () async { // List results = []; await setupPersistorAndLocalDb( throttle: null, saveDuration: duration(300), ); var storeTester = await createStoreTester(); /// Discard the time waiting for the saving of the initial state. await Future.delayed(duration(300)); // At 0 millis: (state:John, db: John) results.add(writeStateAndDb(storeTester, localDb)); // At 0 millis the state is and the save starts: (state:1st, db: John) storeTester.dispatch(ChangeNameAction("1st")); results.add(writeStateAndDb(storeTester, localDb)); // At 100 millis the state is initially unchanged (state:1st, db: John) await Future.delayed(duration(100)); results.add(writeStateAndDb(storeTester, localDb)); // At 100 millis the state is changed, but the previous save hasn't finished: (state:2nd, db: John) storeTester.dispatch(ChangeNameAction("2nd")); results.add(writeStateAndDb(storeTester, localDb)); // At 200 millis the state is unchanged: (state:2nd, db: John) await Future.delayed(duration(100)); results.add(writeStateAndDb(storeTester, localDb)); // Right before 300 millis the state is unchanged: (state:2nd, db: John) await Future.delayed(duration(80)); results.add(writeStateAndDb(storeTester, localDb)); // Right after 300 millis the 1st state is saved: (state:2nd, db: 1st) await Future.delayed(duration(40)); results.add(writeStateAndDb(storeTester, localDb)); // It will take 300 millis more (until 600) to save the 2nd state. // So, at 580 millis we're still at (state:2nd, db: 1st) await Future.delayed(duration(260)); results.add(writeStateAndDb(storeTester, localDb)); // At 620 we're finally finished: (state:2nd, db: 2nd) await Future.delayed(duration(40)); results.add(writeStateAndDb(storeTester, localDb)); printResults(results); expect( results.join(), "(state:John, db: John)" "(state:1st, db: John)" "(state:1st, db: John)" "(state:2nd, db: John)" "(state:2nd, db: John)" "(state:2nd, db: John)" "(state:2nd, db: 1st)" "(state:2nd, db: 1st)" "(state:2nd, db: 2nd)"); }); test( "There is throttle period of 300 millis. " "A first state change happens. A save starts immediately. " "A second state change happens 100 millis after the first. " "However at 150 a PersistAction is dispatched. " "And this saves the second state change right away.", () async { // List results = []; await setupPersistorAndLocalDb( throttle: duration(300), saveDuration: null, ); var storeTester = await createStoreTester(); /// Discard the throttle period for the saving of the initial state. await Future.delayed(duration(300)); // At 0 millis: (state:John, db: John) results.add(writeStateAndDb(storeTester, localDb)); // At 0 millis the state is changed and saved: (state:1st, db: 1st) storeTester.dispatch(ChangeNameAction("1st")); results.add(writeStateAndDb(storeTester, localDb)); // At 100 millis the state is initially unchanged (state:1st, db: 1st) await Future.delayed(duration(100)); results.add(writeStateAndDb(storeTester, localDb)); // At 100 millis the state is changed and saved: (state:2nd, db: 1st) storeTester.dispatch(ChangeNameAction("2nd")); results.add(writeStateAndDb(storeTester, localDb)); // At 150 millis the state is initially unchanged (state:2nd, db: 1st) await Future.delayed(duration(50)); results.add(writeStateAndDb(storeTester, localDb)); // At 150 millis the PersistAction is dispatched. The state is changed: (state:2nd, db: 2nd) storeTester.dispatch(PersistAction()); results.add(writeStateAndDb(storeTester, localDb)); // At 400 millis the state is unchanged (state:2nd, db: 2nd) await Future.delayed(duration(150)); results.add(writeStateAndDb(storeTester, localDb)); printResults(results); expect( results.join(), "(state:John, db: John)" "(state:1st, db: 1st)" "(state:1st, db: 1st)" "(state:2nd, db: 1st)" "(state:2nd, db: 1st)" "(state:2nd, db: 2nd)" "(state:2nd, db: 2nd)"); }); test('Test the persistor in the store holds the correct state.', () async { // await setupPersistorAndLocalDb(); var initialState = AppState.initialState(); var store = Store( initialState: initialState, persistor: persistor, ); // When the store is created with a Persistor, the store considers that the // provided initial-state was already persisted. You have to make sure this is the case. expect(store.getLastPersistedStateFromPersistor(), AppState.initialState()); // Which means it doesn't save the initial-state automatically. var persistedState = await persistor.readState(); expect(persistedState, isNull); var storeTester = StoreTester.from(store); storeTester.dispatch(ChangeNameAction("Mary")); TestInfo info1 = await (storeTester.waitAllGetLast([ChangeNameAction])); expect(await storeTester.store.readStateFromPersistence(), info1.state); expect(store.getLastPersistedStateFromPersistor(), initialState.copy(name: "Mary")); /// If we delete it, it will be null. storeTester.store.deleteStateFromPersistence(); expect(store.getLastPersistedStateFromPersistor(), isNull); }); } String writeStateAndDb(StoreTester storeTester, LocalDb localDb) => "(" "state:${storeTester.state.name}, " "db: ${localDb.get(db: 'main', id: Id('name'))}" ")"; @immutable class AppState { final String? name; AppState({ this.name, }); static AppState initialState() { return AppState(name: "John"); } AppState copy({ String? name, }) => AppState(name: name ?? this.name); @override String toString() => 'AppState{name: $name}'; @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && name == other.name; @override int get hashCode => name.hashCode; } class Id { final String uid; Id(this.uid); @override String toString() => 'Id{uid: $uid}'; @override bool operator ==(Object other) => identical(this, other) || other is Id && runtimeType == other.runtimeType && uid == other.uid; @override int get hashCode => uid.hashCode; } /// T must have [isEmpty] method. abstract class LocalDb { // Map dbs = {}; Set? dbNames; bool get isEmpty => dbs.isEmpty || dbs.values.every((dynamic t) => t.isEmpty); bool get isNotEmpty => !isEmpty; T getDb(String? name) { T? db = dbs[name!]; if (db == null) throw PersistException("Database '$name' does not exist."); return db; } /// This method Must be called right after instantiating the object. /// If it's overridden, you must call super in the beginning. Future init(Iterable dbNames) async { assert(dbNames.isNotEmpty); this.dbNames = dbNames.toSet(); } Future createDatabases(); Future deleteDatabases(); Future save({ String? db, Id? id, required Object? info, }); Object? get({ String? db, Id? id, Object orElse()?, Object deserializer(Object? obj)?, }); Object? getOrThrow({ String? db, Id? id, Object deserializer(Object? obj)?, }); } class NotFound { const NotFound(); static const instance = NotFound(); } class SavedInfo { // final Id id; final Object? info; SavedInfo(this.id, this.info); @override String toString() => identical(this, NotFound.instance) ? "SavedInfo{Not Found}" : 'SavedInfo{id: $id, info: $info}'; @override bool operator ==(Object other) => identical(this, other) || other is SavedInfo && runtimeType == other.runtimeType && id == other.id && info == other.info; @override int get hashCode => id.hashCode ^ info.hashCode; } class LocalDbInMemory extends LocalDb> { // /// Must be called right after instantiating the object. /// The databases will be created as List. @override Future init(Iterable dbNames) async { super.init(dbNames); if (dbs.isNotEmpty) throw PersistException("Databases not empty."); dbNames.forEach((dbName) { dbs[dbName] = []; }); } @override Future createDatabases() => throw AssertionError(); @override Future deleteDatabases() async => dbs.values.forEach((db) => db.clear()); @override Future save({ String? db, Id? id, required Object? info, }) async { assert(db != null); assert(id != null); assert(info != null); var savedInfo = SavedInfo(id!, info); List dbObj = getDb(db); dbObj.add(savedInfo); } /// Searches the LAST change. /// If not found, returns NotFound.instance. /// Will return null if the saved value is null. @override Object? get({ String? db, Id? id, Object orElse()?, Object deserializer(Object? obj)?, }) { assert(db != null); assert(id != null); List dbObj = getDb(db); for (int i = dbObj.length - 1; i >= 0; i--) { var savedInfo = dbObj[i]; if (savedInfo.id == id) return (deserializer == null) ? savedInfo.info : deserializer(savedInfo.info); } if (orElse != null) return orElse(); else return NotFound.instance; } /// Searches the LAST change. /// If not found, returns NotFound.instance. /// Will return null if the saved value is null. @override Object? getOrThrow({ String? db, Id? id, Object deserializer(Object? obj)?, }) { assert(db != null); assert(id != null); var value = get( db: db, id: id, deserializer: deserializer, ); if (value == NotFound.instance) throw PersistException("Can't find: $id in db: $db."); else return value; } } class MyPersistor implements Persistor { // final Duration? _throttle; final Duration? _saveDuration; MyPersistor({ Duration? throttle, Duration? saveDuration, }) : _throttle = throttle, _saveDuration = saveDuration; @override Duration? get throttle => _throttle; Duration? get saveDuration => _saveDuration; LocalDb? _localDb; LocalDb get localDb => _localDb ??= LocalDbInMemory(); Future init() async { localDb.init(["main", "students"]); } @override Future saveInitialState(AppState? state) async { if (localDb.isNotEmpty) throw PersistException("Store is already persisted."); else return persistDifference(lastPersistedState: null, newState: state); } @override Future persistDifference({ AppState? lastPersistedState, required AppState? newState, }) async { assert(newState != null); if (saveDuration != null) await Future.delayed(saveDuration!); if (lastPersistedState == null || lastPersistedState.name != newState!.name) { await localDb.save(db: "main", id: Id("name"), info: newState!.name); } } @override Future readState() async { if (localDb.isEmpty) return null; else return AppState(name: localDb.getOrThrow(db: "main", id: Id("name")) as String?); } @override Future deleteState() async { localDb.deleteDatabases(); } } class ChangeNameAction extends ReduxAction { String name; ChangeNameAction(this.name); @override AppState reduce() => state.copy(name: name); } class X { int value = 0; void printValue(int v) { print('v = $v'); } } ================================================ FILE: test/polling_mixin_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart' hide Retry; void main() { var feature = BddFeature('Polling mixin'); // ========================================================================== // Case 1: Poll.start runs reduce immediately and starts polling // ========================================================================== Bdd(feature) .scenario('Poll.start runs reduce immediately and starts polling') .given('A polling action with Poll.start') .when('The action is dispatched') .then('It should run reduce and schedule periodic timer ticks') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 3); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 2: Poll.start is no-op when polling is already active // ========================================================================== Bdd(feature) .scenario('Poll.start is no-op when polling is already active') .given('Polling is already active for an action type') .when('Poll.start is dispatched again') .then('It should do nothing — no reduce, no timer restart') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Dispatch start again — should be no-op store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // No change // Original timer still ticks fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 3: Poll.stop cancels the timer and skips reduce // ========================================================================== Bdd(feature) .scenario('Poll.stop cancels the timer and skips reduce') .given('Polling is active') .when('Poll.stop is dispatched') .then('The timer should be cancelled and reduce should not run') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Stop did not run reduce // Wait for when ticks would have fired fake.elapse(const Duration(milliseconds: 500)); expect(store.state.count, 1); // No ticks }); }); // ========================================================================== // Case 4: Poll.stop when not active is a safe no-op // ========================================================================== Bdd(feature) .scenario('Poll.stop when not active is a safe no-op') .given('No polling is active') .when('Poll.stop is dispatched') .then('Nothing should happen and no error is thrown') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); expect(store.state.count, 0); // No error, no state change }); }); // ========================================================================== // Case 5: Poll.runNowAndRestart runs reduce and restarts the timer // ========================================================================== Bdd(feature) .scenario('Poll.runNowAndRestart runs reduce immediately and restarts the timer') .given('Polling is active') .when('Poll.runNowAndRestart is dispatched') .then('Reduce should run and the timer should restart from that moment') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Wait 60ms (not enough for a tick at 100ms) fake.elapse(const Duration(milliseconds: 60)); expect(store.state.count, 1); // Poll.runNowAndRestart runs reduce and restarts timer store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Timer restarted from this moment. 60ms later: no tick yet fake.elapse(const Duration(milliseconds: 60)); expect(store.state.count, 2); // 100ms after now: tick fires fake.elapse(const Duration(milliseconds: 40)); expect(store.state.count, 3); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 6: Poll.runNowAndRestart when not active behaves like Poll.start // ========================================================================== Bdd(feature) .scenario('Poll.runNowAndRestart when not active behaves like Poll.start') .given('No polling is active') .when('Poll.runNowAndRestart is dispatched') .then('Reduce should run and polling should start') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 1); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 3); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 7: Poll.once runs reduce without affecting active timer // ========================================================================== Bdd(feature) .scenario('Poll.once runs reduce without affecting the active timer') .given('Polling is active') .when('Poll.once is dispatched') .then('Reduce should run but the timer continues unchanged') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Wait 50ms, then dispatch Poll.once fake.elapse(const Duration(milliseconds: 50)); store.dispatch(SimplePollAction(poll: Poll.once)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Reduce ran // Original timer still fires at 100ms from start fake.elapse(const Duration(milliseconds: 50)); expect(store.state.count, 3); // Timer tick // Next tick at 200ms from start fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 8: Poll.once without active polling just runs reduce once // ========================================================================== Bdd(feature) .scenario('Poll.once without active polling just runs reduce once') .given('No polling is active') .when('Poll.once is dispatched') .then('Reduce runs once and no timer is started') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.once)); fake.elapse(Duration.zero); expect(store.state.count, 1); // No timer should fire fake.elapse(const Duration(milliseconds: 500)); expect(store.state.count, 1); }); }); // ========================================================================== // Case 9: Timer ticks dispatch createPollingAction // ========================================================================== Bdd(feature) .scenario('Timer ticks dispatch the action from createPollingAction') .given('A polling action whose createPollingAction returns a different action type') .when('Timer ticks fire') .then('The action from createPollingAction should be dispatched') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); // ControllerAction increments by 1; its createPollingAction // returns WorkerAction which increments by 10. store.dispatch(ControllerAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Controller ran (+1) fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 11); // Worker ran (+10) fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 21); // Worker ran again (+10) store.dispatch(ControllerAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 10: Long-running polling accumulates correct tick count // ========================================================================== Bdd(feature) .scenario('Long-running polling accumulates correct number of ticks') .given('Polling is active with 100ms interval') .when('1 second passes') .then('There should be 10 timer ticks plus the initial reduce') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); fake.elapse(const Duration(seconds: 1)); expect(store.state.count, 11); // 1 initial + 10 ticks store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 11: Poll.start after Poll.stop restarts polling // ========================================================================== Bdd(feature) .scenario('Poll.start after Poll.stop restarts polling') .given('Polling was active and then stopped') .when('Poll.start is dispatched again') .then('Polling should restart from scratch') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); // Stop store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 300)); expect(store.state.count, 2); // No ticks // Restart store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 3); // Reduce ran immediately fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); // First tick after restart store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 12: Poll.stop in the middle of ticks prevents further ticks // ========================================================================== Bdd(feature) .scenario('Poll.stop in the middle of ticks prevents further ticks') .given('Polling is active and some ticks have fired') .when('Poll.stop is dispatched') .then('No more ticks should fire') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 250)); // ~2 ticks expect(store.state.count, 3); // 1 initial + 2 ticks store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); final countAtStop = store.state.count; fake.elapse(const Duration(seconds: 1)); expect(store.state.count, countAtStop); // No more ticks }); }); // ========================================================================== // Case 13: Different action types have independent timers // ========================================================================== Bdd(feature) .scenario('Different action types have independent timers') .given('Two different action types with Polling') .when('Both are started and one is stopped') .then('The other should continue polling independently') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(PollActionA(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(PollActionB(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Both tick fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); // +1 from A, +1 from B // Stop A only store.dispatch(PollActionA(poll: Poll.stop)); fake.elapse(Duration.zero); // Only B ticks fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 5); // +1 from B only fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 6); // +1 from B only store.dispatch(PollActionB(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 14: pollingKeyParams creates independent timers per param // ========================================================================== Bdd(feature) .scenario('pollingKeyParams creates independent timers per param') .given('A polling action that uses pollingKeyParams') .when('Dispatched with different params') .then('Each param should get its own independent timer') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(ParamPollAction('A', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(ParamPollAction('B', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Both tick independently fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); // Stop only "A" store.dispatch(ParamPollAction('A', poll: Poll.stop)); fake.elapse(Duration.zero); // Only "B" ticks fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 5); // Start "A" again store.dispatch(ParamPollAction('A', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 6); // Both tick fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 8); store.dispatch(ParamPollAction('A', poll: Poll.stop)); store.dispatch(ParamPollAction('B', poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 15: Same pollingKeyParams shares timer // ========================================================================== Bdd(feature) .scenario('Same pollingKeyParams shares a timer') .given('Polling is active for a specific param') .when('Poll.start is dispatched with the same param') .then('It should be a no-op') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(ParamPollAction('X', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Same param, start again — no-op store.dispatch(ParamPollAction('X', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Timer still ticks once fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); store.dispatch(ParamPollAction('X', poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 16: pollingKeyParams with tuple // ========================================================================== Bdd(feature) .scenario('pollingKeyParams with tuple creates independent timers') .given('Actions using tuple pollingKeyParams') .when('Dispatched with different tuple values') .then('Each tuple gets its own timer') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(TupleParamPollAction('u1', 'w1', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(TupleParamPollAction('u1', 'w2', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Same (u1, w1) — no-op store.dispatch(TupleParamPollAction('u1', 'w1', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Both tick fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); store.dispatch(TupleParamPollAction('u1', 'w1', poll: Poll.stop)); store.dispatch(TupleParamPollAction('u1', 'w2', poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 17: computePollingKey shares timer across action types // ========================================================================== Bdd(feature) .scenario('computePollingKey shares a timer across action types') .given('Two action types that return the same computePollingKey') .when('The first starts polling and the second tries to start') .then('The second should be a no-op') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SharedKeyActionA(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // SharedKeyActionB with same key — start is no-op store.dispatch(SharedKeyActionB(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Tick from A's createPollingAction fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); // Stop using B (same shared key) store.dispatch(SharedKeyActionB(poll: Poll.stop)); fake.elapse(Duration.zero); // No more ticks fake.elapse(const Duration(milliseconds: 300)); expect(store.state.count, 2); }); }); // ========================================================================== // Case 18: Poll.runNowAndRestart resets the timer interval // ========================================================================== Bdd(feature) .scenario('Poll.runNowAndRestart resets the timer interval') .given('Polling is active and 80ms have passed (out of 100ms interval)') .when('Poll.runNowAndRestart is dispatched') .then('The timer restarts — next tick is 100ms from now, not 20ms') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Wait 80ms — almost time for the first tick fake.elapse(const Duration(milliseconds: 80)); expect(store.state.count, 1); // Poll.runNowAndRestart resets the timer store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Reduce ran // 80ms after now — no tick (timer was reset to 100ms from now) fake.elapse(const Duration(milliseconds: 80)); expect(store.state.count, 2); // 20ms more = 100ms after now — tick fires fake.elapse(const Duration(milliseconds: 20)); expect(store.state.count, 3); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 19: Rapid start/stop/start cycle // ========================================================================== Bdd(feature) .scenario('Rapid start/stop/start cycle works correctly') .given('Polling is started, stopped, and started again quickly') .when('Timer ticks fire') .then('Only the last start should produce ticks') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Second start ran reduce // Only one timer active fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 3); // One tick store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 20: Multiple Poll.runNowAndRestart dispatches restart each time // ========================================================================== Bdd(feature) .scenario('Multiple Poll.runNowAndRestart dispatches restart the timer each time') .given('Polling is active') .when('Poll.runNowAndRestart is dispatched repeatedly') .then('Each dispatch runs reduce and restarts the timer') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 1); fake.elapse(const Duration(milliseconds: 50)); store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 2); fake.elapse(const Duration(milliseconds: 50)); store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 3); // Timer restarts from last now — tick at +100ms fake.elapse(const Duration(milliseconds: 80)); expect(store.state.count, 3); fake.elapse(const Duration(milliseconds: 20)); expect(store.state.count, 4); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 21: Default key uses (runtimeType, null) // ========================================================================== Bdd(feature) .scenario('Default key is based on (runtimeType, null)') .given('Two instances of the same action type with default pollingKeyParams') .when('One starts and the other tries to start') .then('The second should be a no-op') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(SimplePollAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // No-op store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 22: Option 1 pattern — single action for everything // ========================================================================== Bdd(feature) .scenario('Option 1: single action controls and performs polling') .given('A single action type that handles all poll values') .when('Various poll values are used') .then('It should correctly control polling and run work') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); // Start store.dispatch(SingleAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 2); // Tick // Run once without affecting timer store.dispatch(SingleAction(poll: Poll.once)); fake.elapse(Duration.zero); expect(store.state.count, 3); // Timer still ticks fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 4); // Force refresh + restart store.dispatch(SingleAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 5); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 6); // Stop store.dispatch(SingleAction(poll: Poll.stop)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 300)); expect(store.state.count, 6); // No more ticks }); }); // ========================================================================== // Case 23: Option 2 pattern — separate controller and worker // ========================================================================== Bdd(feature) .scenario('Option 2: separate controller and worker actions') .given('A controller action that dispatches a worker via createPollingAction') .when('Polling starts') .then('Timer ticks should dispatch the worker action') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); // Controller's reduce increments by 1 store.dispatch(ControllerAction(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 1); // Timer dispatches WorkerAction (+10) fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 11); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 21); // Stop via controller store.dispatch(ControllerAction(poll: Poll.stop)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 300)); expect(store.state.count, 21); }); }); // ========================================================================== // Case 24: Clearing internal mixin props cancels all polling timers // ========================================================================== Bdd(feature) .scenario('Clearing internal mixin props cancels all polling timers') .given('Multiple pollers are active') .when('The store internal mixin props are cleared') .then('All polling timers should be cancelled') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(PollActionA(poll: Poll.start)); fake.elapse(Duration.zero); store.dispatch(PollActionB(poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 2); // Clear all mixin props store.internalMixinProps.clear(); // No more ticks from either fake.elapse(const Duration(milliseconds: 500)); expect(store.state.count, 2); }); }); // ========================================================================== // Case 25: Poll.once dispatched many times does not start any timer // ========================================================================== Bdd(feature) .scenario('Multiple Poll.once dispatches never start a timer') .given('No polling is active') .when('Poll.once is dispatched multiple times') .then('Each dispatch runs reduce but no timer is ever created') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.once)); fake.elapse(Duration.zero); store.dispatch(SimplePollAction(poll: Poll.once)); fake.elapse(Duration.zero); store.dispatch(SimplePollAction(poll: Poll.once)); fake.elapse(Duration.zero); expect(store.state.count, 3); // No timer fake.elapse(const Duration(seconds: 1)); expect(store.state.count, 3); }); }); // ========================================================================== // Case 26: Poll.runNowAndRestart followed by Poll.stop stops immediately // ========================================================================== Bdd(feature) .scenario('Poll.runNowAndRestart followed immediately by Poll.stop stops cleanly') .given('Poll.runNowAndRestart is dispatched') .when('Poll.stop is dispatched immediately after') .then('Reduce ran once from now, then no more ticks') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(SimplePollAction(poll: Poll.runNowAndRestart)); fake.elapse(Duration.zero); expect(store.state.count, 1); store.dispatch(SimplePollAction(poll: Poll.stop)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 500)); expect(store.state.count, 1); // No ticks }); }); // ========================================================================== // Case 27: Poll.start with different pollingKeyParams are all independent // ========================================================================== Bdd(feature) .scenario('Stopping one param does not affect other params') .given('Three params are polling independently') .when('One is stopped') .then('The other two continue') .run((_) async { fakeAsync((fake) { var store = Store(initialState: AppState(0)); store.dispatch(ParamPollAction('A', poll: Poll.start)); fake.elapse(Duration.zero); store.dispatch(ParamPollAction('B', poll: Poll.start)); fake.elapse(Duration.zero); store.dispatch(ParamPollAction('C', poll: Poll.start)); fake.elapse(Duration.zero); expect(store.state.count, 3); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 6); // 3 ticks // Stop B store.dispatch(ParamPollAction('B', poll: Poll.stop)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 8); // 2 ticks (A and C) // Stop A store.dispatch(ParamPollAction('A', poll: Poll.stop)); fake.elapse(Duration.zero); fake.elapse(const Duration(milliseconds: 100)); expect(store.state.count, 9); // 1 tick (C only) store.dispatch(ParamPollAction('C', poll: Poll.stop)); fake.elapse(Duration.zero); }); }); // ========================================================================== // Case 28: Polling mixin cannot be combined with Retry // ========================================================================== Bdd(feature) .scenario('Polling mixin cannot be combined with Retry') .given('An action that combines Polling and Retry mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(PollingWithRetryAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The Polling mixin cannot be combined with the Retry mixin.', )), ); }); // ========================================================================== // Case 29: Polling mixin cannot be combined with UnlimitedRetries // ========================================================================== Bdd(feature) .scenario('Polling mixin cannot be combined with UnlimitedRetries') .given('An action that combines Polling and UnlimitedRetries mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(PollingWithUnlimitedRetriesAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The Polling mixin cannot be combined with the Retry mixin.', )), ); }); // ========================================================================== // Case 30: Polling mixin cannot be combined with Debounce // ========================================================================== Bdd(feature) .scenario('Polling mixin cannot be combined with Debounce') .given('An action that combines Polling and Debounce mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(PollingWithDebounceAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The Polling mixin cannot be combined with the Debounce mixin.', )), ); }); // ========================================================================== // Case 31: Polling mixin cannot be combined with UnlimitedRetryCheckInternet // ========================================================================== Bdd(feature) .scenario( 'Polling mixin cannot be combined with UnlimitedRetryCheckInternet') .given( 'An action that combines Polling and UnlimitedRetryCheckInternet mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch( PollingWithUnlimitedRetryCheckInternetAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The UnlimitedRetryCheckInternet mixin cannot be combined with the Polling mixin.', )), ); }); // ========================================================================== // Case 32: Polling mixin cannot be combined with OptimisticCommand // ========================================================================== Bdd(feature) .scenario('Polling mixin cannot be combined with OptimisticCommand') .given('An action that combines Polling and OptimisticCommand mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(PollingWithOptimisticCommandAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The OptimisticCommand mixin cannot be combined with the Polling mixin.', )), ); }); // ========================================================================== // Case 33: Polling mixin cannot be combined with OptimisticSync // ========================================================================== Bdd(feature) .scenario('Polling mixin cannot be combined with OptimisticSync') .given('An action that combines Polling and OptimisticSync mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(PollingWithOptimisticSyncAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The Polling mixin cannot be combined with the OptimisticSync mixin.', )), ); }); // ========================================================================== // Case 34: Polling mixin cannot be combined with OptimisticSyncWithPush // ========================================================================== Bdd(feature) .scenario( 'Polling mixin cannot be combined with OptimisticSyncWithPush') .given( 'An action that combines Polling and OptimisticSyncWithPush mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch( PollingWithOptimisticSyncWithPushAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The Polling mixin cannot be combined with the OptimisticSyncWithPush mixin.', )), ); }); // ========================================================================== // Case 35: Polling mixin cannot be combined with ServerPush // ========================================================================== Bdd(feature) .scenario('Polling mixin cannot be combined with ServerPush') .given('An action that combines Polling and ServerPush mixins') .when('The action is dispatched') .then('It should throw an AssertionError') .run((_) async { var store = Store(initialState: AppState(0)); expect( () => store.dispatch(PollingWithServerPushAction(poll: Poll.once)), throwsA(isA().having( (e) => e.message, 'message', 'The Polling mixin cannot be combined with the ServerPush mixin.', )), ); }); // --------------------------------------------------------------------------- } // ============================================================================= // Test state // ============================================================================= class AppState { final int count; AppState(this.count); AppState copy({int? count}) => AppState(count ?? this.count); @override String toString() => 'AppState($count)'; } // ============================================================================= // Simple polling action — increments count by 1, 100ms interval // ============================================================================= class SimplePollAction extends ReduxAction with Polling { @override final Poll poll; SimplePollAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => SimplePollAction(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // ============================================================================= // Two independent action types for testing independent timers // ============================================================================= class PollActionA extends ReduxAction with Polling { @override final Poll poll; PollActionA({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollActionA(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } class PollActionB extends ReduxAction with Polling { @override final Poll poll; PollActionB({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollActionB(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // ============================================================================= // Action with pollingKeyParams // ============================================================================= class ParamPollAction extends ReduxAction with Polling { final String param; @override final Poll poll; ParamPollAction(this.param, {required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override Object? pollingKeyParams() => param; @override ReduxAction createPollingAction() => ParamPollAction(param, poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // ============================================================================= // Action with tuple pollingKeyParams // ============================================================================= class TupleParamPollAction extends ReduxAction with Polling { final String userId; final String walletId; @override final Poll poll; TupleParamPollAction(this.userId, this.walletId, {required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override Object? pollingKeyParams() => (userId, walletId); @override ReduxAction createPollingAction() => TupleParamPollAction(userId, walletId, poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // ============================================================================= // Shared key across action types // ============================================================================= class SharedKeyActionA extends ReduxAction with Polling { @override final Poll poll; SharedKeyActionA({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override Object computePollingKey() => 'shared-timer'; @override ReduxAction createPollingAction() => SharedKeyActionA(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } class SharedKeyActionB extends ReduxAction with Polling { @override final Poll poll; SharedKeyActionB({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override Object computePollingKey() => 'shared-timer'; @override ReduxAction createPollingAction() => SharedKeyActionB(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // ============================================================================= // Option 1: Single action pattern // ============================================================================= class SingleAction extends ReduxAction with Polling { @override final Poll poll; SingleAction({this.poll = Poll.once}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => SingleAction(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // ============================================================================= // Option 2: Controller + Worker pattern // ============================================================================= class ControllerAction extends ReduxAction with Polling { @override final Poll poll; ControllerAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => WorkerAction(); @override AppState reduce() => state.copy(count: state.count + 1); } class WorkerAction extends ReduxAction { @override AppState reduce() => state.copy(count: state.count + 10); } // ============================================================================= // Incompatible mixin combinations // ============================================================================= // Action that combines Polling with Retry (incompatible) class PollingWithRetryAction extends ReduxAction with Retry, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithRetryAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithRetryAction(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // Action that combines Polling with UnlimitedRetries (incompatible) class PollingWithUnlimitedRetriesAction extends ReduxAction with Retry, UnlimitedRetries, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithUnlimitedRetriesAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithUnlimitedRetriesAction(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // Action that combines Polling with Debounce (incompatible) class PollingWithDebounceAction extends ReduxAction with Debounce, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithDebounceAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithDebounceAction(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // Action that combines Polling with UnlimitedRetryCheckInternet (incompatible) class PollingWithUnlimitedRetryCheckInternetAction extends ReduxAction with UnlimitedRetryCheckInternet, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithUnlimitedRetryCheckInternetAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithUnlimitedRetryCheckInternetAction(poll: Poll.once); @override AppState reduce() => state.copy(count: state.count + 1); } // Action that combines Polling with OptimisticCommand (incompatible) class PollingWithOptimisticCommandAction extends ReduxAction with OptimisticCommand, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithOptimisticCommandAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithOptimisticCommandAction(poll: Poll.once); @override Object? optimisticValue() => null; @override AppState applyValueToState(AppState state, Object? value) => state; @override Object? getValueFromState(AppState state) => null; @override Future sendCommandToServer(Object? optimisticValue) async => null; @override Future reduce() async => state.copy(count: state.count + 1); } // Action that combines Polling with OptimisticSync (incompatible) class PollingWithOptimisticSyncAction extends ReduxAction with OptimisticSync, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithOptimisticSyncAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithOptimisticSyncAction(poll: Poll.once); @override int valueToApply() => 0; @override AppState applyOptimisticValueToState(AppState state, int optimisticValue) => state; @override AppState? applyServerResponseToState(AppState state, Object serverResponse) => null; @override int getValueFromState(AppState state) => state.count; @override Future sendValueToServer(Object? optimisticValue) async => null; @override Future reduce() async => state.copy(count: state.count + 1); } // Action that combines Polling with OptimisticSyncWithPush (incompatible) class PollingWithOptimisticSyncWithPushAction extends ReduxAction with OptimisticSyncWithPush, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithOptimisticSyncWithPushAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithOptimisticSyncWithPushAction(poll: Poll.once); @override int valueToApply() => 0; @override AppState applyOptimisticValueToState(AppState state, int optimisticValue) => state; @override AppState? applyServerResponseToState(AppState state, Object serverResponse) => null; @override int getValueFromState(AppState state) => state.count; @override int getServerRevisionFromState(Object? key) => -1; @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async => null; @override Future reduce() async => state.copy(count: state.count + 1); } // Action that combines Polling with ServerPush (incompatible) class PollingWithServerPushAction extends ReduxAction with ServerPush, // ignore: private_collision_in_mixin_application Polling { @override final Poll poll; PollingWithServerPushAction({required this.poll}); @override Duration get pollInterval => const Duration(milliseconds: 100); @override ReduxAction createPollingAction() => PollingWithServerPushAction(poll: Poll.once); @override Type associatedAction() => SimplePollAction; @override PushMetadata pushMetadata() => (serverRevision: 1, localRevision: 1, deviceId: 1); @override AppState? applyServerPushToState( AppState state, Object? key, int serverRevision) => null; @override int getServerRevisionFromState(Object? key) => -1; @override AppState reduce() => state.copy(count: state.count + 1); } ================================================ FILE: test/props_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('Add and dispose store properties', () { // // If you don't provide a predicate function, all properties which are `Timer`, `Future`, or // `Stream` related will be closed/cancelled/ignored as appropriate, and then removed from the // props. Other properties will not be removed. test('Predicate not provided', () async { final store = Store(initialState: 1); // Future prop final future = Future.delayed(const Duration(seconds: 1), () => 'foo'); store.setProp('future', future); // Timer prop final timer = ManagedTimer(const Duration(milliseconds: 100), () => 'foo'); store.setProp('timer', timer); // Stream prop final sub = Stream.periodic(const Duration(milliseconds: 10), (i) => i).listen((event) {}); store.setProp('subscription', sub); // Regular prop store.setProp('value', 'bar'); expect(timer.isCancelled, isFalse); expect(store.props, hasLength(4)); // Should dispose/cancel the `future` and `subscription` props // but keep the `value` prop. store.disposeProps(); expect(timer.isCancelled, isTrue); expect(store.props, hasLength(1)); expect(store.props.containsKey('value'), isTrue); }); test('Predicate provided, does not remove Future/Timer/Stream', () async { final store = Store(initialState: 1); // Future prop store.setProp('future', Future.delayed(const Duration(seconds: 1), () => 'foo')); // Timer prop final timer = ManagedTimer(const Duration(milliseconds: 100), () => 'foo'); store.setProp('timer', timer); // Stream prop final sub = Stream.periodic(const Duration(milliseconds: 10), (i) => i).listen((event) {}); store.setProp('subscription', sub); // Regular prop store.setProp('value', 'bar'); expect(timer.isCancelled, isFalse); expect(store.props, hasLength(4)); // Predicate: Only remove the regular prop. store.disposeProps(({key, value}) => key == 'value'); // Does NOT close the Timer. expect(timer.isCancelled, isFalse); // Does NOT close the Future/Timer/Stream. // Removes the regular prop. expect(store.props, hasLength(3)); expect(store.props.containsKey('value'), isFalse); }); }); test('Predicate provided, removes Future/Timer/Stream', () async { final store = Store(initialState: 1); // Future prop store.setProp('future', Future.delayed(const Duration(seconds: 1), () => 'foo')); // Timer prop final timer = ManagedTimer(const Duration(milliseconds: 100), () => 'foo'); store.setProp('timer', timer); // Stream prop final sub = Stream.periodic(const Duration(milliseconds: 10), (i) => i).listen((event) {}); store.setProp('subscription', sub); // Regular prop store.setProp('value', 'bar'); expect(timer.isCancelled, isFalse); expect(store.props, hasLength(4)); // Predicate: Only remove the regular prop. store.disposeProps(({key, value}) => true); // Closes the Timer. expect(timer.isCancelled, isTrue); // Removes all. expect(store.props, hasLength(0)); }); } class ManagedTimer implements Timer { Timer? _timer; bool _isCancelled = false; ManagedTimer(Duration duration, void Function() callback) { _timer = Timer(duration, callback); } @override void cancel() { _timer?.cancel(); _isCancelled = true; } bool get isCancelled => _isCancelled; @override bool get isActive => throw UnimplementedError(); @override int get tick => throw UnimplementedError(); } ================================================ FILE: test/reducer_future_or_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// These test makes sure that reducers can return: /// Null reduce() /// AppState reduce() /// AppState? reduce() /// Future reduce() /// Future reduce() /// /// But CANNOT return: /// Future? reduce() /// Future? reduce() /// void main() { test('Test all accepted and rejected reducer return types', () async { // // The initial state is "0". Store store = Store(initialState: AppState.initialState()); await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.text, "0"); // Null reduce() // Doesn't change anything and the state is still "0". store.dispatch(ActionNull()); await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.text, "0"); // AppState reduce() // Adds an "A" to the state. store.dispatch(ActionA()); await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.text, "0A"); // AppState? reduce() // Adds a "B" to the state. store.dispatch(ActionB()); await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.text, "0AB"); // Future reduce() // Adds a "C" to the state. store.dispatch(ActionC()); await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.text, "0ABC"); // Future reduce() // Adds a "D" to the state. store.dispatch(ActionD()); await Future.delayed(const Duration(milliseconds: 50)); expect(store.state.text, "0ABCD"); // ------------ dynamic error1; try { // Future? reduce() await store.dispatch(ActionE()); } catch (error) { error1 = error; } expect( error1, StoreException("Reducer should return `St?` or `Future`. " "Do not return `Future?`.")); // ------------ dynamic error2; try { // Future? reduce() await store.dispatch(ActionF()); } catch (error) { error2 = error; } expect( error2, StoreException("Reducer should return `St?` or `Future`. " "Do not return `Future?`.")); // ------------ dynamic error3; try { // FutureOr reduce() await store.dispatch(ActionG()); } catch (error) { error3 = error; } expect( error3, StoreException("Reducer should return `St?` or `Future`. " "Do not return `FutureOr`.")); // ------------ dynamic error4; try { // FutureOr reduce() await store.dispatch(ActionH()); } catch (error) { error4 = error; } expect( error4, StoreException("Reducer should return `St?` or `Future`. " "Do not return `FutureOr`.")); // ------------ dynamic error5; try { // FutureOr? reduce() await store.dispatch(ActionI()); } catch (error) { error5 = error; } expect( error5, StoreException("Reducer should return `St?` or `Future`. " "Do not return `FutureOr`.")); // ------------ dynamic error6; try { // FutureOr? reduce() await store.dispatch(ActionJ()); } catch (error) { error6 = error; } expect( error6, StoreException("Reducer should return `St?` or `Future`. " "Do not return `FutureOr`.")); // ------------ }); } /// Null reduce() class ActionNull extends ReduxAction { @override Null reduce() { return null; } } /// AppState reduce() class ActionA extends ReduxAction { @override AppState reduce() { return state.copy(state.text + 'A'); } } /// AppState? reduce() class ActionB extends ReduxAction { @override AppState? reduce() { return state.copy(state.text + 'B'); } } /// Future reduce() class ActionC extends ReduxAction { @override Future reduce() async { return state.copy(state.text + 'C'); } } /// Future reduce() class ActionD extends ReduxAction { @override Future reduce() async { return state.copy(state.text + 'D'); } } /// Future? reduce() class ActionE extends ReduxAction { @override Future? reduce() async { return state.copy(state.text + 'E'); } } /// Future? reduce() class ActionF extends ReduxAction { @override Future? reduce() async { return state.copy(state.text + 'F'); } } /// FutureOr reduce() class ActionG extends ReduxAction { @override FutureOr reduce() async { return state.copy(state.text + 'G'); } } /// FutureOr reduce() class ActionH extends ReduxAction { @override FutureOr reduce() async { return state.copy(state.text + 'H'); } } /// FutureOr? reduce() class ActionI extends ReduxAction { @override FutureOr? reduce() async { return state.copy(state.text + 'I'); } } /// FutureOr? reduce() class ActionJ extends ReduxAction { @override FutureOr? reduce() async { return state.copy(state.text + 'J'); } } @immutable class AppState { final String text; AppState(this.text); AppState copy(String? text) => AppState(text ?? this.text); static AppState initialState() => AppState('0'); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && text == other.text; @override int get hashCode => text.hashCode; @override String toString() => text.toString(); } ================================================ FILE: test/retry_mixin_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart' hide Retry; void main() { var feature = BddFeature('Retry actions'); Bdd(feature) .scenario('Action retries a few times and succeeds.') .given('An action that retries up to 10 times.') .and('The action fails with an user exception the first 4 times.') .when('The action is dispatched.') .then('It does change the state.') .run((_) async { var store = Store(initialState: State(1)); expect(store.state.count, 1); var action = ActionThatRetriesAndSucceeds(); await store.dispatchAndWait(action); expect(action.attempts, 5); expect(action.log, '012345'); expect(store.state.count, 2); expect(action.status.isCompletedOk, isTrue); }); Bdd(feature) .scenario('Action retries unlimited tries until it succeeds.') .given('An action marked with "UnlimitedRetries".') .and('The action fails with an user exception the first 6 times.') .when('The action is dispatched.') .then('It does change the state.') .note('Without the "UnlimitedRetries" it would fail because the default is 3 retries.') .run((_) async { var store = Store(initialState: State(1)); expect(store.state.count, 1); var action = ActionThatRetriesUnlimitedAndFails(); await store.dispatchAndWait(action); expect(action.attempts, 7); expect(action.log, '01234567'); expect(store.state.count, 2); expect(action.status.isCompletedOk, isTrue); }); Bdd(feature) .scenario('Action retries a few times and fails.') .given('An action that retries up to 3 times.') .and('The action fails with an user exception the first 4 times.') .when('The action is dispatched.') .then('It does NOT change the state.') .run((_) async { var store = Store(initialState: State(1)); expect(store.state.count, 1); var action = ActionThatRetriesAndFails(); await store.dispatchAndWait(action); expect(store.state.count, 1); expect(action.attempts, 4); expect(action.log, '0123'); expect(action.status.isCompletedFailed, isTrue); }); Bdd(feature) .scenario('Sync action becomes ASYNC of it retries, even if it succeeds the first time.') .given('A SYNC action that retries up to 10 times.') .when('The action is dispatched and succeeds the first time.') .then('It cannot be dispatched SYNC anymore.') .run((_) async { var store = Store(initialState: State(1)); expect(store.state.count, 1); var action = ActionThatRetriesButSucceedsTheFirstTry(); await store.dispatchAndWait(action); expect(action.attempts, 0); expect(action.log, '0'); expect(store.state.count, 2); expect(action.status.isCompletedOk, isTrue); // The action cannot be dispatched SYNC anymore. expect(() => store.dispatchSync(action), throwsA(isA())); }); } class State { final int count; State(this.count); @override String toString() => 'State($count)'; } class ActionThatRetriesAndSucceeds extends ReduxAction with Retry { @override Duration get initialDelay => const Duration(milliseconds: 10); @override int maxRetries = 10; String log = ''; @override State reduce() { log += attempts.toString(); if (attempts <= 4) throw UserException('Failed: $attempts'); return State(state.count + 1); } } class ActionThatRetriesAndFails extends ReduxAction with Retry { @override Duration get initialDelay => const Duration(milliseconds: 10); String log = ''; @override State reduce() { log += attempts.toString(); if (attempts <= 4) throw UserException('Failed: $attempts'); return State(state.count + 1); } } class ActionThatRetriesButSucceedsTheFirstTry extends ReduxAction with Retry { @override Duration get initialDelay => const Duration(milliseconds: 10); @override int maxRetries = 10; String log = ''; @override State reduce() { log += attempts.toString(); return State(state.count + 1); } } class ActionThatRetriesUnlimitedAndFails extends ReduxAction with Retry, UnlimitedRetries { @override Duration get initialDelay => const Duration(milliseconds: 10); String log = ''; @override State reduce() { log += attempts.toString(); if (attempts <= 6) throw UserException('Failed: $attempts'); return State(state.count + 1); } } ================================================ FILE: test/server_push_init_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { final feature = BddFeature('ServerPush mixin initialization'); setUp(() { resetTestState(); }); Bdd(feature) .scenario( 'Init: Fresh server response applies when backend revision >= persisted.') .given('App launched with state.serverRevision=100 and backend at 100.') .when('User dispatches OptimisticSyncWithPush action.') .then('Response is applied and serverRevision increases to 101.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 100)); backend = SimulatedBackend(liked: false, serverRevision: 100); await store.dispatchAndWait(ToggleLikeStableAction()); expect(store.state.liked, true); expect(store.state.serverRevision, 101); expect(backend.serverRevision, 101); }); Bdd(feature) .scenario('Init: Fresh push updates both backend and store.') .given('App launched with state.serverRevision=100 and backend at 100.') .when('A fresh push arrives with serverRevision=105.') .then('Both store and backend end at 105 with same liked value.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 100)); backend = SimulatedBackend(liked: false, serverRevision: 100); // Simulate a fresh push from another device (backend already updated). backend.applyPush(true, 105); await store.dispatchAndWait(PushLikeUpdate(liked: true, serverRev: 105)); expect(store.state.liked, true); expect(store.state.serverRevision, 105); expect(backend.liked, true); expect(backend.serverRevision, 105); }); Bdd(feature) .scenario( 'Init: Stale push is ignored using persisted serverRevision in state.') .given( 'App launched with state.serverRevision=100 and revisionMap empty.') .when('A ServerPush arrives with serverRevision=99.') .then('Push is ignored and state does not regress.') .run((_) async { final store = Store( initialState: AppState(liked: true, serverRevision: 100)); // Backend reflects the persisted state (already at rev 100). backend = SimulatedBackend(liked: true, serverRevision: 100); // Simulate a delayed/stale push arriving. The backend has already moved on, // so we only deliver the stale message to the client (no applyPush call). await store.dispatchAndWait(PushLikeUpdate(liked: false, serverRev: 99)); expect(store.state.liked, true); expect(store.state.serverRevision, 100); }); Bdd(feature) .scenario('Init: Push with equal serverRevision is ignored at startup.') .given( 'App launched with state.serverRevision=100 and revisionMap empty.') .when( 'A ServerPush arrives with serverRevision=100 and a different liked value.') .then('The push is ignored and state remains unchanged.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 100)); // Backend reflects the persisted state (already at rev 100). backend = SimulatedBackend(liked: false, serverRevision: 100); // Push arrives with equal revision but different value. // Should be ignored because it's not newer. await store.dispatchAndWait(PushLikeUpdate(liked: true, serverRev: 100)); expect(store.state.liked, false); expect(store.state.serverRevision, 100); }); Bdd(feature) .scenario( 'Init: Stale server response is ignored using persisted serverRevision in state.') .given( 'App launched with state.serverRevision=100 and a request returns serverRev=1.') .when('A OptimisticSyncWithPush action completes.') .then( 'The stale response is not applied and serverRevision does not regress.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 100)); // Simulate a stale backend that starts at revision 0, so first response returns 1. backend = SimulatedBackend(liked: false, serverRevision: 0); await store.dispatchAndWait(ToggleLikeStableAction()); // Optimistic UI happened. expect(store.state.liked, true); // But serverRevision must not go backwards. expect(store.state.serverRevision, 100); expect(backend.requestLog.length, 1); expect(backend.requestLog.first, contains('localRev=1')); }); Bdd(feature) .scenario( 'Init: OptimisticSyncWithPush seeds revisionMap from persisted state so ServerPush ordering works even if push cannot read state.') .given('App launched with state.serverRevision=100.') .when( 'A OptimisticSyncWithPush request starts (in flight) and a stale push arrives, but the push action returns null from getServerRevisionFromState.') .then('The stale push is still ignored because revisionMap was seeded.') .note( 'This verifies the seeding path: OptimisticSyncWithPush must copy the persisted serverRevision into revisionMap.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 100)); // Backend starts at revision 100, will go to 101 when request completes. backend = SimulatedBackend(liked: false, serverRevision: 100); // Hold request so we can inject push while the request is in flight. requestCompleter = Completer(); requestStarted = Completer(); requestFinished = Completer(); // Start request: this must seed revisionMap from state.serverRevision=100. store.dispatch(ToggleLikeStableAction()); await requestStarted!.future; // Wait until request is in flight. expect(store.state.liked, true); expect(store.state.serverRevision, 100); // Stale push arrives (delayed message). Backend has already moved on, // so we only deliver the stale message to the client. await store .dispatchAndWait(PushLikeUpdateNoStateRev(liked: false, serverRev: 99)); // If seeding didn't happen, this would incorrectly apply and set serverRevision=99. expect(store.state.serverRevision, 100); // Now complete the request (backend will increment to 101 and apply). requestCompleter!.complete(); await requestFinished!.future; // Wait until action finishes. expect(store.state.serverRevision, 101); expect(store.state.liked, true); }); Bdd(feature) .scenario( 'Init: Per-key persisted revisions are honored when revisionMap is empty.') .given('App launched with serverRevById[A]=100 and serverRevById[B]=0.') .when('A stale push arrives for A and a fresh push arrives for B.') .then('A push is ignored and B push is applied.') .run((_) async { final store = Store( initialState: AppStateItems.initialWithRevs( likedById: {'A': false, 'B': false}, serverRevById: {'A': 100, 'B': 0}, ), ); // Initialize backends to match persisted state. backendByItem['A'] = SimulatedBackend(liked: false, serverRevision: 100); backendByItem['B'] = SimulatedBackend(liked: false, serverRevision: 0); // Stale push for A (older than persisted 100) must be ignored. // Backend has already moved on, so no applyPush call. await store.dispatchAndWait( PushItemLikeUpdate(itemId: 'A', liked: true, serverRev: 99)); expect(store.state.likedById['A'], false); expect(store.state.serverRevById['A'], 100); // Fresh push for B should apply (backend updated first). backendByItem['B']!.applyPush(true, 1); await store.dispatchAndWait( PushItemLikeUpdate(itemId: 'B', liked: true, serverRev: 1)); expect(store.state.likedById['B'], true); expect(store.state.serverRevById['B'], 1); }); Bdd(feature) .scenario( 'Init: OptimisticSyncWithPush seeds per-key revisionMap from persisted state for item keys.') .given('App launched with serverRevById[A]=100.') .when( 'A OptimisticSyncWithPush request for A starts, and a stale push for A arrives that cannot read serverRev from state.') .then( 'The stale push is ignored because revisionMap was seeded for key A.') .run((_) async { final store = Store( initialState: AppStateItems.initialWithRevs( likedById: {'A': false, 'B': false}, serverRevById: {'A': 100, 'B': 0}, ), ); // Backend for item A starts at revision 100. backendByItem['A'] = SimulatedBackend(liked: false, serverRevision: 100); requestCompleterByItem['A'] = Completer(); requestStartedByItem['A'] = Completer(); requestFinishedByItem['A'] = Completer(); // Start request for A: must seed revisionMap for key A from state (100). store.dispatch(ToggleLikeItemStableAction('A')); await requestStartedByItem['A']!.future; // Wait until request is in flight. expect(store.state.likedById['A'], true); expect(store.state.serverRevById['A'], 100); // Stale push for A (delayed message). Backend has already moved on. await store.dispatchAndWait( PushItemLikeUpdateNoStateRev(itemId: 'A', liked: false, serverRev: 99)); // If seeding didn't happen, this would regress serverRevById[A] to 99. expect(store.state.serverRevById['A'], 100); // Finish request so the test doesn't leave in-flight work behind. requestCompleterByItem['A']!.complete(); await requestFinishedByItem['A']!.future; // Wait until action finishes. expect(store.state.serverRevById['A'], 101); }); } // ============================================================================= // Simulated Backend // ============================================================================= /// Simulates a backend server that maintains its own state. /// When it receives a value, it stores it and increments the serverRevision. class SimulatedBackend { bool liked; int serverRevision; final List requestLog = []; SimulatedBackend({required this.liked, required this.serverRevision}); /// Applies a push that originated from the server (e.g., another device). /// Only updates if the revision is newer than current. void applyPush(bool value, int rev) { requestLog.add('applyPush($value, serverRev=$rev)'); if (rev > serverRevision) { serverRevision = rev; liked = value; } } /// Simulates sending a value to the server. /// The server stores the value and returns the new state with incremented revision. ({bool value, int serverRevision}) receiveValue(bool value, int localRev) { requestLog.add('receiveValue($value, localRev=$localRev)'); liked = value; serverRevision++; return (value: liked, serverRevision: serverRevision); } } // ============================================================================= // Shared test state // ============================================================================= class AppState { final bool liked; final int serverRevision; AppState({required this.liked, this.serverRevision = 0}); AppState copy({bool? liked, int? serverRevision}) => AppState( liked: liked ?? this.liked, serverRevision: serverRevision ?? this.serverRevision, ); } class AppStateItems { final Map likedById; final Map serverRevById; AppStateItems({required this.likedById, required this.serverRevById}); factory AppStateItems.initialWithRevs({ required Map likedById, required Map serverRevById, }) => AppStateItems(likedById: likedById, serverRevById: serverRevById); AppStateItems copy({ Map? likedById, Map? serverRevById, }) => AppStateItems( likedById: likedById ?? this.likedById, serverRevById: serverRevById ?? this.serverRevById, ); AppStateItems setLiked(String id, bool liked) => copy(likedById: {...likedById, id: liked}); AppStateItems setServerRev(String id, int rev) => copy(serverRevById: {...serverRevById, id: rev}); } // ============================================================================= // Test control variables // ============================================================================= late SimulatedBackend backend; Map backendByItem = {}; Completer? requestCompleter; Completer? requestStarted; Completer? requestFinished; Map?> requestCompleterByItem = {}; Map?> requestStartedByItem = {}; Map?> requestFinishedByItem = {}; void resetTestState() { backend = SimulatedBackend(liked: false, serverRevision: 0); backendByItem = {}; requestCompleter = null; requestStarted = null; requestFinished = null; requestCompleterByItem = {}; requestStartedByItem = {}; requestFinishedByItem = {}; } // ============================================================================= // Actions // ============================================================================= class ToggleLikeStableAction extends ReduxAction with OptimisticSyncWithPush { @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(AppState state, bool optimisticValue) => state.copy(liked: optimisticValue); @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { final response = serverResponse as ({bool value, int serverRevision}); return state.copy( liked: response.value, serverRevision: response.serverRevision, ); } @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { // Signal: request is now in-flight (it will block on requestCompleter). // Use isCompleted guard to handle follow-up requests safely. if (requestStarted != null && !requestStarted!.isCompleted) { requestStarted!.complete(); } if (requestCompleter != null) { await requestCompleter!.future; requestCompleter = null; } final response = backend.receiveValue(optimisticValue as bool, localRevision); informServerRevision(response.serverRevision); return response; } @override Future onFinish(Object? error) async { // Use isCompleted guard to handle follow-up requests safely. if (requestFinished != null && !requestFinished!.isCompleted) { requestFinished!.complete(); } return null; } @override int getServerRevisionFromState(Object? key) => state.serverRevision; } class PushLikeUpdate extends ReduxAction with ServerPush { final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; PushLikeUpdate({ required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; @override Type associatedAction() => ToggleLikeStableAction; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppState? applyServerPushToState( AppState state, Object? key, int serverRevision) { return state.copy(liked: liked, serverRevision: serverRevision); } @override int getServerRevisionFromState(Object? key) => state.serverRevision; } /// Same as PushLikeUpdate but pretends it cannot read persisted revision from state. /// Used to prove OptimisticSyncWithPush seeded revisionMap. class PushLikeUpdateNoStateRev extends ReduxAction with ServerPush { final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; PushLikeUpdateNoStateRev({ required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; @override Type associatedAction() => ToggleLikeStableAction; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppState? applyServerPushToState( AppState state, Object? key, int serverRevision) { return state.copy(liked: liked, serverRevision: serverRevision); } @override int getServerRevisionFromState(Object? key) => -1; } class ToggleLikeItemStableAction extends ReduxAction with OptimisticSyncWithPush { final String itemId; ToggleLikeItemStableAction(this.itemId); @override Object? optimisticSyncKeyParams() => itemId; @override bool valueToApply() => !(state.likedById[itemId] ?? false); @override bool getValueFromState(AppStateItems state) => state.likedById[itemId] ?? false; @override AppStateItems applyOptimisticValueToState( AppStateItems state, bool optimisticValue) { return state.setLiked(itemId, optimisticValue); } @override AppStateItems? applyServerResponseToState( AppStateItems state, Object serverResponse) { final response = serverResponse as ({bool value, int serverRevision}); return state .setLiked(itemId, response.value) .setServerRev(itemId, response.serverRevision); } @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { // Signal: request is now in-flight (it will block on requestCompleterByItem). // Use isCompleted guard to handle follow-up requests safely. final started = requestStartedByItem[itemId]; if (started != null && !started.isCompleted) { started.complete(); } final c = requestCompleterByItem[itemId]; if (c != null) { await c.future; requestCompleterByItem[itemId] = null; } // Get or create backend, seeding from persisted state. final itemBackend = backendByItem[itemId] ?? SimulatedBackend( liked: state.likedById[itemId] ?? false, serverRevision: state.serverRevById[itemId] ?? 0, ); backendByItem[itemId] = itemBackend; final response = itemBackend.receiveValue(optimisticValue as bool, localRevision); informServerRevision(response.serverRevision); return response; } @override Future onFinish(Object? error) async { // Use isCompleted guard to handle follow-up requests safely. final finished = requestFinishedByItem[itemId]; if (finished != null && !finished.isCompleted) { finished.complete(); } return null; } @override int getServerRevisionFromState(Object? key) { final k = key is String ? key : itemId; return state.serverRevById[k] ?? -1; } } class PushItemLikeUpdate extends ReduxAction with ServerPush { final String itemId; final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; PushItemLikeUpdate({ required this.itemId, required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; @override Type associatedAction() => ToggleLikeItemStableAction; @override Object? optimisticSyncKeyParams() => itemId; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppStateItems? applyServerPushToState( AppStateItems state, Object? key, int serverRevision) { return state.setLiked(itemId, liked).setServerRev(itemId, serverRevision); } @override int getServerRevisionFromState(Object? key) { final k = key is String ? key : itemId; return state.serverRevById[k] ?? -1; } } class PushItemLikeUpdateNoStateRev extends ReduxAction with ServerPush { final String itemId; final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; PushItemLikeUpdateNoStateRev({ required this.itemId, required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; @override Type associatedAction() => ToggleLikeItemStableAction; @override Object? optimisticSyncKeyParams() => itemId; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppStateItems? applyServerPushToState( AppStateItems state, Object? key, int serverRevision) { return state.setLiked(itemId, liked).setServerRev(itemId, serverRevision); } @override int getServerRevisionFromState(Object? key) => -1; } ================================================ FILE: test/server_push_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('ServerPush mixin'); setUp(() { resetTestState(); }); Bdd(feature) .scenario('BUG: Remote newer push can be overwritten by local follow-up.') .given('A OptimisticSyncWithPush action has a request in flight ' 'and localRevision advanced.') .when('A ServerPush arrives with a newer serverRevision from another ' 'device before the request completes.') .then('The mixin must not send a follow-up that fights the ' 'newer serverRevision.') .note('Last write wins: newer serverRevision supersedes pending local ' 'intent for that key.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; final req1 = Completer(); requestCompleter = req1; // Tap #1: false -> true, localRev=1, request in flight. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Tap #2 while in flight: true -> false, localRev=2 (pending local intent differs from sent). store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); // Remote device push arrives with much newer serverRev, and a different value. store.dispatch(PushLikeUpdate(liked: true, serverRev: 50)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 50); // Complete request #1 (serverRev=11, should be stale vs 50). req1.complete(); await Future.delayed(const Duration(milliseconds: 200)); final sendValueLogs = requestLog.where((s) => s.startsWith('sendValue(')).toList(); // Correct: no follow-up should be sent to override a newer serverRevision. expect(sendValueLogs.length, 1, reason: 'Should not fight newer serverRevision with a follow-up request.'); // Correct: remote push remains the final truth. expect(store.state.liked, true); expect(store.state.serverRevision, 50); }); Bdd(feature) .scenario('ServerPush does not increment localRevision.') .given( 'A OptimisticSyncWithPush action where requests log localRev values.') .when('A ServerPush action is dispatched between local taps.') .then('The next local request uses the next localRevision ' 'as if the push never happened.') .note('Pushes must not be treated as local intent.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; await store.dispatchAndWait(ToggleLikeStableAction()); expect(requestLog.where((s) => s.startsWith('sendValue(')).length, 1); store.dispatch(PushLikeUpdate(liked: false, serverRev: 99)); await Future.delayed(const Duration(milliseconds: 10)); await store.dispatchAndWait(ToggleLikeStableAction()); final sendValueLogs = requestLog.where((s) => s.startsWith('sendValue(')).toList(); expect(sendValueLogs.length, 2); expect(sendValueLogs[0], contains('localRev=1')); expect(sendValueLogs[1], contains('localRev=2'), reason: 'Push must not consume a local revision number.'); }); Bdd(feature) .scenario( 'ServerPush applies immediately even while the stable-sync key is locked.') .given( 'A OptimisticSyncWithPush action has a request in flight for a key.') .when( 'A ServerPush arrives for the same key with a newer serverRevision.') .then( 'The pushed value is applied immediately and the stale response is ignored.') .note( 'Immediate apply is required even when locked; staleness must be deterministic.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; final req1 = Completer(); requestCompleter = req1; // Tap #1: optimistic true, request in flight. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Push arrives while locked: apply immediately. store.dispatch(PushLikeUpdate(liked: false, serverRev: 12)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); expect(store.state.serverRevision, 12); // Complete request (serverRev=11) -> must be ignored as stale. req1.complete(); await Future.delayed(const Duration(milliseconds: 200)); expect(store.state.liked, false); expect(store.state.serverRevision, 12); }); Bdd(feature) .scenario( 'ServerPush keying: push for item B does not interfere with item A in flight.') .given( 'Two OptimisticSync keys A and B, and a request is in flight for A.') .when('A ServerPush arrives for B and then for A.') .then( 'Both pushes apply immediately to their own keys and do not affect the other key lock or revisions.') .note( 'Verifies optimisticSyncKeyParams and computeOptimisticSyncKey alignment between OptimisticSyncWithPush and ServerPush.') .run((_) async { var store = Store(initialState: AppStateItems.initial()); nextServerRevision = 1; final reqA = Completer(); requestCompleterByItem['A'] = reqA; // Start request for A (locked for key A). store.dispatch(ToggleLikeItemStableAction('A')); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.likedById['A'], true); // Push for B applies immediately (independent key). store.dispatch(PushItemLikeUpdate(itemId: 'B', liked: true, serverRev: 5)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.likedById['B'], true); expect(store.state.serverRevById['B'], 5); // Push for A applies immediately even though A is locked. store.dispatch(PushItemLikeUpdate(itemId: 'A', liked: false, serverRev: 6)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.likedById['A'], false); expect(store.state.serverRevById['A'], 6); // Complete request for A (will return serverRev=1, stale vs 6 -> should be ignored). reqA.complete(); await Future.delayed(const Duration(milliseconds: 200)); expect(store.state.likedById['A'], false); expect(store.state.serverRevById['A'], 6); // Ensure B stayed untouched by A completion. expect(store.state.likedById['B'], true); expect(store.state.serverRevById['B'], 5); final sendValueLogs = requestLog.where((s) => s.startsWith('sendValue(')).toList(); expect(sendValueLogs.length, 1); expect(sendValueLogs.first, contains('item=A')); }); Bdd(feature) .scenario('ServerPush ignores pushes with equal serverRevision.') .given('Client already applied serverRevision=20 for a key.') .when( 'A ServerPush arrives with serverRevision=20 and a different value.') .then('The push is ignored and state does not regress or flap.') .note('Ordering rule should be strictly greater than current.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 0)); store.dispatch(PushLikeUpdate(liked: true, serverRev: 20)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 20); // Same serverRev, different value -> must be ignored. store.dispatch(PushLikeUpdate(liked: false, serverRev: 20)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 20); }); Bdd(feature) .scenario( 'Optimization preserved with pushes: no follow-up if final local value equals sent value.') .given( 'A OptimisticSyncWithPush action with request 1 in flight and ServerPush updates may arrive.') .when( 'User changes intent during the request but ends back at the original sent value.') .then( 'No follow-up request is sent, even if a push overwrote the store temporarily.') .note( 'Ensures the revision-based path keeps the same coalescing optimization as the value-based path.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 10)); nextServerRevision = 11; final req1 = Completer(); requestCompleter = req1; // Tap #1: false -> true, localRev=1, request in flight (sent true). store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Tap #2: true -> false, localRev=2. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); // Tap #3: false -> true, localRev=3 (back to sent value). store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Push overwrites store temporarily to false (same rev we'll later apply from response). store.dispatch(PushLikeUpdate(liked: false, serverRev: 11)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); // Complete request #1. req1.complete(); await Future.delayed(const Duration(milliseconds: 200)); final sendValueLogs = requestLog.where((s) => s.startsWith('sendValue(')).toList(); expect(sendValueLogs.length, 1, reason: 'Should not send follow-up when final local value equals sent value.'); // Response should restore true (no follow-up needed). expect(store.state.liked, true); expect(store.state.serverRevision, 11); }); Bdd(feature) .scenario( 'Stale ServerPush does not overwrite local optimistic UI while request is in flight.') .given( 'A OptimisticSyncWithPush action has a request in flight and the store is optimistic.') .when( 'A ServerPush arrives with an older serverRevision for the same key.') .then( 'The push is ignored immediately and the optimistic UI state remains unchanged.') .note('Covers out-of-order delivery while locked.') .run((_) async { var store = Store( initialState: AppState(liked: false, serverRevision: 0)); // Seed known server revision in the mixin bookkeeping. store.dispatch(PushLikeUpdate(liked: false, serverRev: 20)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.serverRevision, 20); nextServerRevision = 21; final req1 = Completer(); requestCompleter = req1; // Tap: optimistic false -> true, request in flight. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 20); // Stale push arrives (older than 20) -> must be ignored. store.dispatch(PushLikeUpdate(liked: false, serverRev: 19)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 20); // Complete request -> serverRev=21 (newer) should apply. req1.complete(); await Future.delayed(const Duration(milliseconds: 200)); expect(store.state.liked, true); expect(store.state.serverRevision, 21); }); Bdd(feature) .scenario( 'Stale server response is NOT applied when a newer ServerPush arrives before the response.') .given('A OptimisticSyncWithPush action has a request in flight.') .when('A ServerPush arrives with a newer serverRevision before the ' 'request completes.') .then('The stale response is ignored and the pushed state remains.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 10), ); // Make request 1 return serverRev=11. nextServerRevision = 11; // Hold request 1 so we can inject push before response. final c = Completer(); requestCompleter = c; // Tap #1: optimistic false -> true, localRev=1. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Newer push arrives from another device BEFORE request 1 completes. store.dispatch(PushLikeUpdate(liked: false, serverRev: 12)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); expect(store.state.serverRevision, 12); // Now let request 1 complete (it would try to apply serverRev=11). c.complete(); await Future.delayed(const Duration(milliseconds: 100)); // Because ServerPush updated the mixin's revision map to 12, // the response with serverRev=11 must be treated as stale and ignored. expect(store.state.liked, false); expect(store.state.serverRevision, 12); }); Bdd(feature) .scenario('Out-of-order pushes are ignored by ServerPush ordering.') .given('A ServerPush has already applied serverRevision=20.') .when('A ServerPush arrives with an older serverRevision=19.') .then('The older push is ignored and state does not regress.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 0), ); // First push (new). store.dispatch(PushLikeUpdate(liked: true, serverRev: 20)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 20); // Older push should be ignored even if it tries to overwrite. store.dispatch(PushLikeUpdate(liked: false, serverRev: 19)); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); expect(store.state.serverRevision, 20); }); Bdd(feature) .scenario( 'Self-echo push does not break follow-up and preserves optimistic state.') .given('A OptimisticSyncWithPush action has a request in flight.') .when('User taps again and a self-echo push arrives.') .then('The self-echo is ignored (not applied) and follow-up still sends latest local intent.') .note( 'Self-echo is detected by matching deviceId and stale localRevision.') .run((_) async { final store = Store( initialState: AppState(liked: false, serverRevision: 10), ); // Make request 1 wait; it will return serverRev=11, and follow-up will be 12. nextServerRevision = 11; final c = Completer(); requestCompleter = c; // Tap #1: false -> true (optimistic), localRev=1; request 1 starts. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, true); // Tap #2 while request 1 in flight: true -> false (optimistic), localRev=2. store.dispatch(ToggleLikeStableAction()); await Future.delayed(const Duration(milliseconds: 10)); expect(store.state.liked, false); // Self-echo push arrives (same deviceId, stale localRev=1). // With the new ServerPush logic, self-echoes are NOT applied to state, // preserving the optimistic value (false). store.dispatch(PushLikeUpdate( liked: true, serverRev: 11, pushLocalRevision: 1, // Stale: less than currentLocalRev=2 pushDeviceId: OptimisticSyncWithPush.deviceId(), // Same device = self-echo )); await Future.delayed(const Duration(milliseconds: 10)); // Self-echo is NOT applied, so state remains false (optimistic from tap 2). expect(store.state.liked, false, reason: 'Self-echo should not be applied to state'); // Finish request 1, causing OptimisticSyncWithPush to detect localRev advanced // and send follow-up with the latest local intent (false). c.complete(); await Future.delayed(const Duration(milliseconds: 200)); final sendLogs = requestLog.where((s) => s.startsWith('sendValue(')).toList(); // First request should be true, localRev=1. expect(sendLogs.first, 'sendValue(true, localRev=1)'); // Follow-up must send false, localRev=2. expect(sendLogs.length, greaterThanOrEqualTo(2)); expect(sendLogs[1], 'sendValue(false, localRev=2)'); // Final should reflect the follow-up (newer serverRev=12). expect(store.state.liked, false); expect(store.state.serverRevision, 12); }); } // ============================================================================= // Shared test state // ============================================================================= class AppState { final bool liked; final int serverRevision; AppState({required this.liked, this.serverRevision = 0}); AppState copy({bool? liked, int? serverRevision}) => AppState( liked: liked ?? this.liked, serverRevision: serverRevision ?? this.serverRevision, ); @override String toString() => 'AppState(liked: $liked, serverRev: $serverRevision)'; } class AppStateItems { final Map likedById; final Map serverRevById; AppStateItems({required this.likedById, required this.serverRevById}); factory AppStateItems.initial() => AppStateItems( likedById: {'A': false, 'B': false}, serverRevById: {'A': 0, 'B': 0}, ); AppStateItems copy({ Map? likedById, Map? serverRevById, }) => AppStateItems( likedById: likedById ?? this.likedById, serverRevById: serverRevById ?? this.serverRevById, ); AppStateItems setLiked(String id, bool liked) => copy(likedById: {...likedById, id: liked}); AppStateItems setServerRev(String id, int rev) => copy(serverRevById: {...serverRevById, id: rev}); @override String toString() => 'AppStateItems(likedById: $likedById, serverRevById: $serverRevById)'; } // ============================================================================= // Test control variables // ============================================================================= List requestLog = []; Completer? requestCompleter; Map?> requestCompleterByItem = {}; int nextServerRevision = 1; void resetTestState() { requestLog = []; requestCompleter = null; requestCompleterByItem = {}; nextServerRevision = 1; } // ============================================================================= // Actions // ============================================================================= class ToggleLikeStableAction extends ReduxAction with OptimisticSyncWithPush { int _serverRevFromResponse = 0; @override bool valueToApply() => !state.liked; @override bool getValueFromState(AppState state) => state.liked; @override AppState applyOptimisticValueToState(AppState state, bool optimisticValue) => state.copy(liked: optimisticValue); @override AppState? applyServerResponseToState(AppState state, Object serverResponse) { return state.copy( liked: serverResponse as bool, serverRevision: _serverRevFromResponse, ); } @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { requestLog.add('sendValue($optimisticValue, localRev=$localRevision)'); if (requestCompleter != null) { await requestCompleter!.future; requestCompleter = null; } _serverRevFromResponse = nextServerRevision++; informServerRevision(_serverRevFromResponse); return optimisticValue; } @override Future onFinish(Object? error) async { requestLog.add('onFinish()'); return null; } @override int getServerRevisionFromState(Object? key) { return state.serverRevision; } } class PushLikeUpdate extends ReduxAction with ServerPush { final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; PushLikeUpdate({ required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; // Default to a different deviceId @override Type associatedAction() => ToggleLikeStableAction; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppState? applyServerPushToState( AppState state, Object? key, int serverRevision) { return state.copy(liked: liked, serverRevision: serverRevision); } @override int getServerRevisionFromState(Object? key) { return state.serverRevision; } } class ToggleLikeItemStableAction extends ReduxAction with OptimisticSyncWithPush { final String itemId; int _serverRevFromResponse = 0; ToggleLikeItemStableAction(this.itemId); @override Object? optimisticSyncKeyParams() => itemId; @override bool valueToApply() => !(state.likedById[itemId] ?? false); @override bool getValueFromState(AppStateItems state) => state.likedById[itemId] ?? false; @override AppStateItems applyOptimisticValueToState( AppStateItems state, bool optimisticValue) { return state.setLiked(itemId, optimisticValue); } @override AppStateItems? applyServerResponseToState( AppStateItems state, Object serverResponse) { return state .setLiked(itemId, serverResponse as bool) .setServerRev(itemId, _serverRevFromResponse); } @override Future sendValueToServer( Object? optimisticValue, int localRevision, int deviceId, ) async { requestLog .add('sendValue(item=$itemId, value=$optimisticValue, localRev=$localRevision)'); final c = requestCompleterByItem[itemId]; if (c != null) { await c.future; requestCompleterByItem[itemId] = null; } _serverRevFromResponse = nextServerRevision++; informServerRevision(_serverRevFromResponse); return optimisticValue; } @override Future onFinish(Object? error) async { requestLog.add('onFinish(item=$itemId)'); return null; } @override int getServerRevisionFromState(Object? key) { return state.serverRevById[key] ?? -1; } } class PushItemLikeUpdate extends ReduxAction with ServerPush { final String itemId; final bool liked; final int serverRev; final int pushLocalRevision; final int pushDeviceId; PushItemLikeUpdate({ required this.itemId, required this.liked, required this.serverRev, this.pushLocalRevision = 0, int? pushDeviceId, }) : pushDeviceId = pushDeviceId ?? -999; // Default to a different deviceId @override Type associatedAction() => ToggleLikeItemStableAction; @override Object? optimisticSyncKeyParams() => itemId; @override PushMetadata pushMetadata() => ( serverRevision: serverRev, localRevision: pushLocalRevision, deviceId: pushDeviceId, ); @override AppStateItems? applyServerPushToState( AppStateItems state, Object? key, int serverRevision) { return state.setLiked(itemId, liked).setServerRev(itemId, serverRevision); } @override int getServerRevisionFromState(Object? key) { return state.serverRevById[key] ?? -1; } } ================================================ FILE: test/store_connector_test.dart ================================================ import "package:async_redux/async_redux.dart"; import "package:flutter/material.dart"; import "package:flutter_test/flutter_test.dart"; void main() { group(StoreConnector, () { // TODO shouldUpdateModel does not currently work with converter testWidgets( "shouldUpdateModel.converter", (tester) async { final storeTester = StoreTester(initialState: 0); await tester.pumpWidget(StoreProvider( store: storeTester.store, child: MaterialApp( home: StoreConnector( converter: (store) => store.state, shouldUpdateModel: (state) => state % 2 == 0, builder: (context, value) { return Text(value.toString()); }, ), ), )); expect(find.text("0"), findsOneWidget); await storeTester.dispatchState(1); await tester.pumpAndSettle(); expect(find.text("0"), findsOneWidget); await storeTester.dispatchState(2); await tester.pumpAndSettle(); expect(find.text("2"), findsOneWidget); }, skip: true, ); testWidgets( "shouldUpdateModel.vm", (tester) async { final store = Store(initialState: 0); await tester.pumpWidget(StoreProvider( store: store, child: _TestWidget(), )); expect(find.text("0"), findsOneWidget); store.dispatch(UpdateStateAction(1)); await tester.pumpAndSettle(); expect(find.text("0"), findsOneWidget); store.dispatch(UpdateStateAction(2)); await tester.pumpAndSettle(); expect(find.text("2"), findsOneWidget); }, ); testWidgets( "shouldUpdateModel.vm with external rebuild", (tester) async { final store = Store(initialState: 0); await tester.pumpWidget(StoreProvider( store: store, child: _TestWidget(), )); expect(find.text("0"), findsOneWidget); store.dispatch(UpdateStateAction(1)); await tester.pump(); await tester.pump(); expect(find.text("0"), findsOneWidget); tester.firstState<_TestWidgetState>(find.byType(_TestWidget)).forceRebuild(); await tester.pump(); await tester.pump(); expect(find.text("0"), findsOneWidget); }, ); testWidgets( "When the observed state changes, the widget rebuilds", (tester) async { final store = Store( initialState: AppState( text: 'x', boolean: false, ), ); await tester.pumpWidget(StoreProvider( store: store, child: _AnotherConnector(), )); // Initially, that's what we have. expect(find.text("text: x / boolean: false"), findsOneWidget); store.dispatch(UpdateStateAction( AppState(text: 'y', boolean: false), )); await tester.pump(); await tester.pump(); expect(find.text("text: y / boolean: false"), findsOneWidget); store.dispatch(UpdateStateAction( AppState(text: 'y', boolean: true), )); await tester.pump(); await tester.pump(); expect(find.text("text: y / boolean: true"), findsOneWidget); }, ); testWidgets( "When 'isWaiting' changes, the widget rebuilds (notify true)", (tester) async { final store = Store(initialState: 1); await tester.pumpWidget(StoreProvider( store: store, child: _IsWaitingConnector(), )); // 1) Initially, we're NOT waiting. expect(find.text("isWaiting: false"), findsOneWidget); // 2) When we dispatch an ASYNC action, we ARE waiting. store.dispatch(_AsyncChangeStateAction(), notify: true); await tester.pump(); expect(find.text("isWaiting: true"), findsOneWidget); // 3) After 99 milliseconds we are STILL waiting, because the action takes 100ms to finish. await tester.pump(const Duration(milliseconds: 99)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: true"), findsOneWidget); // 4) After 1 more millisecond we are FINISHED WAITING, as the action finished. await tester.pump(const Duration(milliseconds: 1)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: false"), findsOneWidget); }, ); testWidgets( "When 'isWaiting' changes, the widget rebuilds (notify false)", (tester) async { final store = Store(initialState: 1); await tester.pumpWidget(StoreProvider( store: store, child: _IsWaitingConnector(), )); // 1) Initially, we're NOT waiting. expect(find.text("isWaiting: false"), findsOneWidget); // 2) When we dispatch an ASYNC action, we ARE waiting. store.dispatch(_AsyncChangeStateAction(), notify: false); await tester.pump(); expect(find.text("isWaiting: true"), findsOneWidget); // 3) After 99 milliseconds we are STILL waiting, because the action takes 100ms to finish. await tester.pump(const Duration(milliseconds: 99)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: true"), findsOneWidget); // 4) After 1 more millisecond we are FINISHED WAITING, as the action finished. await tester.pump(const Duration(milliseconds: 1)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: false"), findsOneWidget); }, ); testWidgets( "When 'isWaiting' changes, the widget rebuilds. " "The action fails with a dialog", (tester) async { final store = Store(initialState: 1); await tester.pumpWidget(StoreProvider( store: store, child: _IsWaitingConnector(), )); // 1) Initially, we're NOT waiting. expect(find.text("isWaiting: false"), findsOneWidget); // 2) When we dispatch an ASYNC action, we ARE waiting. store.dispatch(_AsyncChangeStateAction(failWithDialog: true)); await tester.pump(); expect(find.text("isWaiting: true"), findsOneWidget); // 3) After 99 milliseconds we are STILL waiting, because the action takes 100ms to finish. await tester.pump(const Duration(milliseconds: 99)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: true"), findsOneWidget); // 4) After 1 more millisecond we are FINISHED WAITING, as the action finished. await tester.pump(const Duration(milliseconds: 1)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: false"), findsOneWidget); }, ); testWidgets( "When 'isWaiting' changes, the widget rebuilds. " "The action fails with no dialog", (tester) async { final store = Store(initialState: 1); await tester.pumpWidget(StoreProvider( store: store, child: _IsWaitingConnector(), )); // 1) Initially, we're NOT waiting. expect(find.text("isWaiting: false"), findsOneWidget); // 2) When we dispatch an ASYNC action, we ARE waiting. store.dispatch(_AsyncChangeStateAction(failNoDialog: true)); await tester.pump(); expect(find.text("isWaiting: true"), findsOneWidget); // 3) After 99 milliseconds we are STILL waiting, because the action takes 100ms to finish. await tester.pump(const Duration(milliseconds: 99)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: true"), findsOneWidget); // 4) After 1 more millisecond we are FINISHED WAITING, as the action finished. await tester.pump(const Duration(milliseconds: 1)); print('store.state.text = ${'isWaiting: ${store.isWaiting(_AsyncChangeStateAction)}'}'); expect(find.text("isWaiting: false"), findsOneWidget); }, ); }); } class _TestWidget extends StatefulWidget { @override State<_TestWidget> createState() => _TestWidgetState(); } class _TestWidgetState extends State<_TestWidget> { void forceRebuild() => setState(() {}); @override Widget build(BuildContext context) { return MaterialApp( home: _TestContent(key: const ValueKey("tester")), ); } } class _TestContent extends StatelessWidget { _TestContent({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return StoreConnector( vm: () => Factory(this), shouldUpdateModel: (state) => state % 2 == 0, builder: (context, vm) { return Text(vm.counter.toString()); }, ); } } class Factory extends VmFactory { Factory(connector) : super(connector); @override ViewModel fromStore() { return ViewModel( counter: state, ); } } class ViewModel extends Vm { final int counter; ViewModel({ required this.counter, }) : super(equals: [counter]); } //////////////////////////////////////////////////////////////////////////////////////////////////// class _AnotherWidget extends StatelessWidget { final String text; final bool boolean; const _AnotherWidget({ required this.text, required this.boolean, }); @override Widget build(BuildContext context) { return MaterialApp( home: Text('text: $text / boolean: $boolean'), ); } } class _AnotherConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => AnotherFactory(this), builder: (context, vm) { return _AnotherWidget( text: vm.text, boolean: vm.boolean, ); }, ); } } class AnotherFactory extends VmFactory { AnotherFactory(connector) : super(connector); @override AnotherViewModel fromStore() { return AnotherViewModel( text: state.text, boolean: state.boolean, ); } } class AnotherViewModel extends Vm { final String text; final bool boolean; AnotherViewModel({ required this.text, required this.boolean, }) : super(equals: [text, boolean]); } class AppState { final String text; final bool boolean; AppState({ required this.text, required this.boolean, }); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && text == other.text && boolean == other.boolean; @override int get hashCode => text.hashCode ^ boolean.hashCode; @override String toString() { return 'AppState{text: $text, boolean: $boolean}'; } } //////////////////////////////////////////////////////////////////////////////////////////////////// class _IsWaitingWidget extends StatelessWidget { final bool isWaiting; const _IsWaitingWidget({required this.isWaiting}); @override Widget build(BuildContext context) { return MaterialApp(home: Text('isWaiting: $isWaiting')); } } class _IsWaitingConnector extends StatelessWidget { @override Widget build(BuildContext context) { return StoreConnector( vm: () => IsWaitingFactory(this), builder: (context, vm) { return _IsWaitingWidget(isWaiting: vm.isWaiting); }, ); } } class IsWaitingFactory extends VmFactory { IsWaitingFactory(connector) : super(connector); @override IsWaitingViewModel fromStore() { return IsWaitingViewModel( isWaiting: isWaiting(_AsyncChangeStateAction), ); } } class IsWaitingViewModel extends Vm { final bool isWaiting; IsWaitingViewModel({ required this.isWaiting, }) : super(equals: [isWaiting]); } class _AsyncChangeStateAction extends ReduxAction { // final bool failWithDialog; final bool failNoDialog; _AsyncChangeStateAction({ this.failWithDialog = false, this.failNoDialog = false, }); @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 100)); if (failWithDialog) throw const UserException('Fail'); if (failNoDialog) throw const UserException('Fail').noDialog; return null; } } ================================================ FILE: test/store_observer_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; class _MyAction extends ReduxAction { final num number; _MyAction(this.number); @override num reduce() => number; } class _MyAsyncAction extends ReduxAction { final num number; _MyAsyncAction(this.number); @override Future reduce() async { await Future.sync(() {}); return number; } } class _MyStateObserver extends StateObserver { num? iniValue; num? endValue; @override void observe( ReduxAction action, num? stateIni, num? stateEnd, Object? error, int dispatchCount, ) { iniValue = stateIni; endValue = stateEnd; } } void main() { var observer = _MyStateObserver(); StoreTester createStoreTester() { var store = Store(initialState: 0, stateObservers: [observer]); return StoreTester.from(store); } test('Dispatch a sync action, see what the StateObserver picks up. ', () async { var storeTester = createStoreTester(); expect(storeTester.state, 0); storeTester.dispatch(_MyAction(1)); var condition = (TestInfo? info) => info!.state == 1; await storeTester.waitConditionGetLast(condition); expect(observer.iniValue, 0); expect(observer.endValue, 1); }); test('Dispatch an async action, see what the StateObserver picks up.', () async { var storeTester = createStoreTester(); expect(storeTester.state, 0); storeTester.dispatch(_MyAsyncAction(1)); var condition = (TestInfo? info) => info!.state == 1; await storeTester.waitConditionGetLast(condition); expect(observer.iniValue, 0); expect(observer.endValue, 1); }); } ================================================ FILE: test/store_provider_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('backdoorStaticGlobal', () async { Store store = Store(initialState: "abc"); StoreProvider(store: store, child: Container()); expect(StoreProvider.backdoorStaticGlobal(), store); expect(StoreProvider.backdoorStaticGlobal(), store); expect(StoreProvider.backdoorStaticGlobal(), store); expect(() => StoreProvider.backdoorStaticGlobal(), throwsA(isA())); var backdoorStore = StoreProvider.backdoorStaticGlobal(); expect(backdoorStore, store); var backdoorState = StoreProvider.backdoorStaticGlobal().state; expect(backdoorState, "abc"); }); } ================================================ FILE: test/store_tester_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @immutable class AppState { final String text; AppState(this.text); AppState.add(AppState state, String text) : text = state.text + "," + text; } class Action1 extends ReduxAction { @override AppState reduce() => AppState.add(state, "1"); } class Action2 extends ReduxAction { @override AppState reduce() => AppState.add(state, "2"); } class Action3 extends ReduxAction { @override AppState reduce() => AppState.add(state, "3"); } class Action3b extends ReduxAction { @override AppState reduce() { dispatch(Action4()); return AppState.add(state, "3b"); } } class Action4 extends ReduxAction { @override AppState reduce() => AppState.add(state, "4"); } class Action5 extends ReduxAction { @override AppState reduce() => AppState.add(state, "5"); } class Action6 extends ReduxAction { @override AppState reduce() { dispatch(Action1()); dispatch(Action2()); dispatch(Action3()); return AppState.add(state, "6"); } } class Action6b extends ReduxAction { @override Future reduce() async { dispatch(Action1()); await Future.delayed(const Duration(milliseconds: 10)); dispatch(Action2()); dispatch(Action3()); return AppState.add(state, "6b"); } } class Action6c extends ReduxAction { @override AppState reduce() { dispatch(Action1()); dispatch(Action2()); dispatch(Action3b()); return AppState.add(state, "6c"); } } class Action7 extends ReduxAction { @override Future reduce() async { dispatch(Action4()); dispatch(Action6()); dispatch(Action2()); dispatch(Action5()); return AppState.add(state, "7"); } } class Action7b extends ReduxAction { @override Future reduce() async { dispatch(Action4()); dispatch(Action6b()); dispatch(Action2()); dispatch(Action5()); return AppState.add(state, "7b"); } } class Action8 extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 50)); dispatch(Action2()); return AppState.add(state, "8"); } } class Action9 extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 100)); return AppState.add(state, "9"); } } class Action10a extends ReduxAction { @override Future reduce() async { dispatch(Action1()); dispatch(Action2()); dispatch(Action11a()); dispatch(Action3()); return AppState.add(state, "10"); } } class Action10b extends ReduxAction { @override Future reduce() async { dispatch(Action1()); dispatch(Action2()); await dispatch(Action11b()); dispatch(Action3()); return AppState.add(state, "10"); } } class Action10c extends ReduxAction { @override Future reduce() async { dispatch(Action1()); dispatch(Action2()); dispatch(Action11b()); dispatch(Action3()); return AppState.add(state, "10"); } } class Action11a extends ReduxAction { @override AppState reduce() { throw const UserException("Hello!"); } } class Action11b extends ReduxAction { @override Future reduce() async { throw const UserException("Hello!"); } } class Action12 extends ReduxAction { @override AppState reduce() { dispatch(Action13()); return AppState.add(state, "12"); } } class Action13 extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 1)); return AppState.add(state, "13"); } } void main() { StoreTester createStoreTester() { var store = Store(initialState: AppState("0")); return StoreTester.from(store); } test('Dispatch multiple actions but only issue a single change event.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); int invocations = 0; storeTester.store.onChange.listen((event) { invocations += 1; }); await storeTester.dispatch(Action1()); await storeTester.dispatch(Action2()); await storeTester.dispatch(Action3()); await storeTester.dispatch(Action4()); expect(invocations, 4); expect(storeTester.state.text, "0,1,2,3,4"); storeTester = createStoreTester(); expect(storeTester.state.text, "0"); invocations = 0; storeTester.store.onChange.listen((event) { invocations += 1; }); await storeTester.dispatch(Action1(), notify: false); await storeTester.dispatch(Action2(), notify: false); await storeTester.dispatch(Action3(), notify: false); await storeTester.dispatch(Action4(), notify: true); expect(invocations, 1); expect(storeTester.state.text, "0,1,2,3,4"); }); test( 'Dispatch some actions and wait until some condition is met. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); var condition = (TestInfo? info) => info!.state!.text == "0,1,2"; TestInfo info1 = await (storeTester.waitConditionGetLast(condition)); expect(info1.state!.text, "0,1,2"); expect(info1.ini, false); TestInfo info2 = await (storeTester.waitConditionGetLast((info) => info.state.text == "0,1,2,3,4")); expect(info2.state!.text, "0,1,2,3,4"); expect(info2.ini, false); }); test( 'Dispatch some actions and wait until some condition is met. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); var condition = (TestInfo? info) => info!.state!.text == "0,1,2" && info.ini; TestInfo info1 = await (storeTester.waitConditionGetLast(condition, ignoreIni: false)); expect(info1.state!.text, "0,1,2"); expect(info1.ini, true); TestInfo info2 = await (storeTester.waitConditionGetLast( (info) => info.state.text == "0,1,2,3,4" && !info.ini, ignoreIni: false)); expect(info2.state!.text, "0,1,2,3,4"); expect(info2.ini, false); }); test( 'Dispatch some actions and wait until some condition is met. ' 'Get all of the intermediary states (END only).', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); TestInfoList infos = await storeTester.waitCondition((info) => info.state.text == "0,1,2"); expect(infos.length, 2); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(0).ini, false); expect(infos.getIndex(1).state!.text, "0,1,2"); expect(infos.getIndex(1).ini, false); infos = await storeTester.waitCondition((info) => info.state.text == "0,1,2,3,4"); expect(infos.length, 2); expect(infos.getIndex(0).state!.text, "0,1,2,3"); expect(infos.getIndex(0).ini, false); expect(infos.getIndex(1).state!.text, "0,1,2,3,4"); expect(infos.getIndex(1).ini, false); }); test( 'Dispatch some actions and wait until some condition is met. ' 'Get all of the intermediary states, ' 'including INI and END.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); TestInfoList infos = await storeTester.waitCondition((info) => info.state.text == "0,1,2", ignoreIni: false); expect(infos.length, 4); expect(infos.getIndex(0).state!.text, "0"); expect(infos.getIndex(0).ini, true); expect(infos.getIndex(1).state!.text, "0,1"); expect(infos.getIndex(1).ini, false); expect(infos.getIndex(2).state!.text, "0,1"); expect(infos.getIndex(2).ini, true); expect(infos.getIndex(3).state!.text, "0,1,2"); expect(infos.getIndex(3).ini, false); infos = await storeTester.waitCondition((info) => info.state.text == "0,1,2,3,4", ignoreIni: false); expect(infos.length, 4); expect(infos.getIndex(0).state!.text, "0,1,2"); expect(infos.getIndex(0).ini, true); expect(infos.getIndex(1).state!.text, "0,1,2,3"); expect(infos.getIndex(1).ini, false); expect(infos.getIndex(2).state!.text, "0,1,2,3"); expect(infos.getIndex(2).ini, true); expect(infos.getIndex(3).state!.text, "0,1,2,3,4"); expect(infos.getIndex(3).ini, false); }); test( 'Dispatch some action and wait for it. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); TestInfo info = await (storeTester.wait(Action1)); expect(info.state!.text, "0,1"); expect(info.errors, isEmpty); }); test( 'Dispatch some action and wait for a different one. ' 'Gets an error.', () async { var storeTester = createStoreTester(); storeTester.dispatch(Action1()); // await storeTester.wait(Action2); await storeTester.wait(Action2).then( (_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1( (Object error) { expect(error, const TypeMatcher()); expect( error.toString(), 'Got this unexpected action: Action1 INI.\n' 'Was expecting: Action2 INI.\n' 'obtainedIni: [Action1]\n' 'ignoredIni: []'); }, ), ); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); TestInfo info = await (storeTester.waitAllGetLast([Action1, Action2, Action3])); expect(info.state!.text, "0,1,2,3"); expect(info.errors, isEmpty); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); // Action6 will dispatch actions 1, 2 and 3, and only then it will finish. storeTester.dispatch(Action6()); TestInfo info = await (storeTester.waitAllGetLast([Action6, Action1, Action2, Action3])); expect(info.state!.text, "0,1,2,3,6"); expect(info.errors, isEmpty); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Gets an error because they are not in order.', () async { var storeTester = createStoreTester(); storeTester.dispatch(Action1()); storeTester.dispatch(Action3()); storeTester.dispatch(Action2()); await storeTester.waitAllGetLast([Action1, Action2, Action3]).then((_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1((Object error) { expect(error, const TypeMatcher()); expect( error.toString(), 'Got this unexpected action: Action3 INI.\n' 'Was expecting: Action2 INI.\n' 'obtainedIni: [Action1, Action3]\n' 'ignoredIni: []'); })); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Gets an error because a different one was dispatched in the middle.', () async { var storeTester = createStoreTester(); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); storeTester.dispatch(Action3()); await storeTester.waitAllGetLast([Action1, Action2, Action3]).then((_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1((Object error) { expect(error, const TypeMatcher()); expect( error.toString(), 'Got this unexpected action: Action4 INI.\n' 'Was expecting: Action3 INI.\n' 'obtainedIni: [Action1, Action2, Action4]\n' 'ignoredIni: []'); })); }); test( 'Dispatch a few actions and wait until one of them is dispatched, ' 'ignoring the others.' 'Get the end state after this action.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); TestInfo info = await storeTester.waitUntil(Action3); expect(info.state!.text, "0,1,2,3"); expect(info.errors, isEmpty); }); test( 'Dispatch a few actions and wait until all of them finish, ' 'ignoring the others.' 'Get the end state after all actions finish.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action1()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); TestInfo info = await storeTester.waitUntilAllGetLast([Action3, Action2]); expect(info.state!.text, "0,1,2,1,3"); expect(info.errors, isEmpty); }); test( 'Dispatch a few actions and wait until all of them finish, ' 'ignoring the others.' 'Get all states until all actions finish.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action1()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); TestInfoList infos = await storeTester.waitUntilAll([Action3, Action2]); expect(infos.length, 4); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(1).state!.text, "0,1,2"); expect(infos.getIndex(2).state!.text, "0,1,2,1"); expect(infos.getIndex(3).state!.text, "0,1,2,1,3"); }); test( 'Wait until some action that is never dispatched.' 'Should timeout.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); await storeTester.waitUntil(Action3, timeoutInSeconds: 1).then((_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1((Object error) { expect(error, const TypeMatcher()); expect(error.toString(), "Timeout."); })); }); test( 'Dispatch a few actions and wait until one specific action instance is dispatched, ' 'ignoring the others.' 'Get the end state after this action.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); var action3 = Action3(); storeTester.dispatch(action3); storeTester.dispatch(Action4()); TestInfo info = await storeTester.waitUntilAction(action3); expect(info.state!.text, "0,1,2,3"); expect(info.errors, isEmpty); }); test( 'Wait until some action that is never dispatched.' 'Should timeout.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); await storeTester.waitUntilAction(Action3(), timeoutInSeconds: 1).then((_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1((Object error) { expect(error, const TypeMatcher()); expect(error.toString(), "Timeout."); })); }); test( 'Dispatch a few actions and wait for all of them, in ANY order. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); TestInfo info = await (storeTester.waitAllUnorderedGetLast([Action3, Action1, Action2])); expect(info.state!.text, "0,1,2,3"); expect(info.errors, isEmpty); }); test( 'Dispatch a few actions and wait for all of them, in ANY order. ' 'Gets an error because there is a different one in the middle.', () async { var storeTester = createStoreTester(); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); storeTester.dispatch(Action3()); await storeTester.waitAllUnorderedGetLast([Action1, Action2, Action3]).then((_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1((Object error) { expect(error, const TypeMatcher()); expect(error.toString(), "Unexpected action was dispatched: Action4 INI."); })); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); TestInfoList infos = await storeTester.waitAll([Action1, Action2, Action3]); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(1).state!.text, "0,1,2"); expect(infos.getIndex(2).state!.text, "0,1,2,3"); expect(infos.getIndex(0).errors, isEmpty); expect(infos.getIndex(1).errors, isEmpty); expect(infos.getIndex(2).errors, isEmpty); }); test( 'Dispatch a few actions and wait for all of them, in ANY order. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); TestInfoList infos = await storeTester .waitAllUnordered([Action1, Action2, Action3, Action2], timeoutInSeconds: 1); // The states are indexed by order of dispatching // (doesn't matter the order we were expecting them). expect(infos.length, 4); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(1).state!.text, "0,1,2"); expect(infos.getIndex(2).state!.text, "0,1,2,2"); expect(infos.getIndex(3).state!.text, "0,1,2,2,3"); expect(infos.getIndex(0).errors, isEmpty); expect(infos.getIndex(1).errors, isEmpty); expect(infos.getIndex(2).errors, isEmpty); expect(infos.getIndex(3).errors, isEmpty); // Can get first and last. expect(infos.first.state!.text, "0,1"); expect(infos.last.state!.text, "0,1,2,2,3"); // Number of infos. expect(infos.length, 4); expect(infos.isEmpty, false); expect(infos.isNotEmpty, true); // It's usually better to get them by type, not order. expect(infos[Action1]!.state!.text, "0,1"); expect(infos[Action2]!.state!.text, "0,1,2"); expect(infos[Action3]!.state!.text, "0,1,2,2,3"); // Operator [] is the same as get(). expect(infos.get(Action1)!.state!.text, "0,1"); expect(infos.get(Action2)!.state!.text, "0,1,2"); expect(infos.get(Action3)!.state!.text, "0,1,2,2,3"); // But get is useful if some action is repeated, then you can get by type and repeating order. expect(infos.get(Action1, 1)!.state!.text, "0,1"); expect(infos.get(Action2, 1)!.state!.text, "0,1,2"); expect(infos.get(Action2, 2)!.state!.text, "0,1,2,2"); expect(infos.get(Action3, 1)!.state!.text, "0,1,2,2,3"); // If the action is not repeated to that order, return null; expect(infos.get(Action3, 2), isNull); expect(infos.get(Action3, 500), isNull); // Get repeated actions as list. List?> action2s = infos.getAll(Action2); expect(action2s.length, 2); expect(action2s[0]!.state!.text, "0,1,2"); expect(action2s[1]!.state!.text, "0,1,2,2"); }); test( 'Dispatch a few actions and wait for all of them, in ANY order. ' 'Ignore some actions. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); TestInfoList infos = await storeTester.waitAllUnordered( [Action1, Action3], timeoutInSeconds: 1, ignore: [Action2], ); // The states are indexed by order of dispatching // (doesn't matter the order we were expecting them). expect(infos.length, 2); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(1).state!.text, "0,1,2,2,3"); expect(infos.getIndex(0).errors, isEmpty); expect(infos.getIndex(1).errors, isEmpty); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Ignore some actions. ' 'Get the end state.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action4()); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); storeTester.dispatch(Action5()); storeTester.dispatch(Action4()); TestInfo info = await (storeTester.waitAllGetLast( [Action1, Action3, Action5], ignore: [Action2, Action4], )); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(info.state!.text, "0,4,1,2,2,3,4,2,4,5"); expect(info.errors, isEmpty); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Ignore some actions. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action4()); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); storeTester.dispatch(Action5()); storeTester.dispatch(Action4()); TestInfoList infos = await storeTester.waitAll( [Action1, Action3, Action5], ignore: [Action2, Action4], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,4,1,2,2,3,4,2,4,5"); expect(infos.last.errors, isEmpty); // Only 3 states were collected. The ignored action doesn't generate info. expect(infos.length, 3); expect(infos.getIndex(0).state!.text, "0,4,1"); expect(infos.getIndex(1).state!.text, "0,4,1,2,2,3"); expect(infos.getIndex(2).state!.text, "0,4,1,2,2,3,4,2,4,5"); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Ignore some actions, including one which we are also waiting for it. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); // We are waiting for this Action2 (after Action1) which would otherwise be ignored. storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); TestInfoList infos = await storeTester.waitAll( [Action1, Action2, Action3], ignore: [Action2], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,1,2,3"); expect(infos.last.errors, isEmpty); // Only 3 states were collected. The ignored action doesn't generate info. expect(infos.length, 3); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(1).state!.text, "0,1,2"); expect(infos.getIndex(2).state!.text, "0,1,2,3"); }); test( 'Dispatch a few actions and wait for all of them, in order. ' 'Ignore some actions, including one which we are also waiting for it. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action4()); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); // We are waiting for this Action4 (after Action3) which is otherwise ignored. storeTester.dispatch(Action4()); storeTester.dispatch(Action2()); storeTester.dispatch(Action4()); storeTester.dispatch(Action5()); storeTester.dispatch(Action4()); TestInfoList infos = await storeTester.waitAll( [Action1, Action3, Action4, Action5], ignore: [Action2, Action4], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,4,1,2,2,3,4,2,4,5"); expect(infos.last.errors, isEmpty); // Only 4 states were collected. The ignored action doesn't generate info. expect(infos.length, 4); expect(infos.getIndex(0).state!.text, "0,4,1"); expect(infos.getIndex(1).state!.text, "0,4,1,2,2,3"); expect(infos.getIndex(2).state!.text, "0,4,1,2,2,3,4"); expect(infos.getIndex(3).state!.text, "0,4,1,2,2,3,4,2,4,5"); }); test( 'Dispatch a few actions, some async that dispatch others, ' 'and wait for all of them, in order. ' 'Ignore some actions, including one which we are also waiting for it. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); // Action6 will dispatch actions 1, 2 and 3, and only then it will finish. storeTester.dispatch(Action6()); TestInfoList infos = await storeTester.waitAll( [Action6, Action1, Action2, Action3], ignore: [Action6], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,1,2,3,6"); expect(infos.last.errors, isEmpty); // Only 4 states were collected. The ignored action doesn't generate info. expect(infos.length, 4); expect(infos.getIndex(0).state!.text, "0,1"); expect(infos.getIndex(1).state!.text, "0,1,2"); expect(infos.getIndex(2).state!.text, "0,1,2,3"); expect(infos.getIndex(3).state!.text, "0,1,2,3,6"); }); test( 'Dispatch a more complex action sequence. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); // Action6 will dispatch actions 1, 2 and 3, and only then it will finish. storeTester.dispatch(Action7()); TestInfoList infos = await storeTester.waitAll( [Action7, Action4, Action6, Action1, Action2, Action3, Action5], ignore: [Action2], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,4,1,2,3,6,2,5,7"); expect(infos.last.errors, isEmpty); // Only 7 states were collected. The ignored action doesn't generate info. expect(infos.length, 7); expect(infos.getIndex(0).state!.text, "0,4"); expect(infos.getIndex(1).state!.text, "0,4,1"); expect(infos.getIndex(2).state!.text, "0,4,1,2"); expect(infos.getIndex(3).state!.text, "0,4,1,2,3"); expect(infos.getIndex(4).state!.text, "0,4,1,2,3,6"); expect(infos.getIndex(5).state!.text, "0,4,1,2,3,6,2,5"); expect(infos.getIndex(6).state!.text, "0,4,1,2,3,6,2,5,7"); }); test( 'Dispatch a more complex action sequence. ' 'One of the actions contains "await". ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); // Action6b will dispatch actions 1, 2 and 3, and only then it will finish. storeTester.dispatch(Action7b()); TestInfoList infos = await storeTester.waitAll( [Action7b, Action4, Action6b, Action1, Action2, Action5, Action2, Action3], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,4,1,2,5,7b,2,3,6b"); expect(infos.last.errors, isEmpty); // All 8 states were collected. expect(infos.length, 8); }); test( 'Dispatch a more complex actions sequence. ' 'One of the actions contains "await". ' 'Ignore an action. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); // Action6b will dispatch actions 1, 2 and 3, and only then it will finish. storeTester.dispatch(Action7b()); TestInfoList infos = await storeTester.waitAll( [Action7b, Action4, Action6b, Action1, Action2, Action5, Action3], ignore: [Action2], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,4,1,2,5,7b,2,3,6b"); expect(infos.last.errors, isEmpty); // Only 7 states were collected. The ignored action doesn't generate info. expect(infos.length, 7); }); test( 'Dispatch a more complex actions sequence. ' 'An ignored action will finish after all others have started. ' 'Get all of the intermediary states.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); // Action9 will dispatch Action2 after some delay. storeTester.dispatch(Action8()); storeTester.dispatch(Action9()); TestInfoList infos = await storeTester.waitAll( [ Action9, Action2, ], ignore: [Action8], ); // All actions affect the state, even the ones ignored by the store-tester. // However, ignored action can run any number of times. expect(infos.last.state!.text, "0,2,8,9"); expect(infos.last.errors, isEmpty); // Only 2 states were collected. The ignored action doesn't generate info. expect(infos.length, 2); expect(infos.getIndex(0).state!.text, "0,2"); expect(infos.getIndex(1).state!.text, "0,2,8,9"); }); test( 'An ignored action starts after the last expected actions starts, ' 'but before this last expected action finishes.', () async { // var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action9()); storeTester.dispatch(Action1()); storeTester.dispatch(Action9()); storeTester.dispatch(Action1()); TestInfoList infos = await storeTester.waitAll( [ Action9, Action9, ], ignore: [Action1], ); expect(infos.last.state!.text, "0,1,1,9,9"); expect(infos.last.errors, isEmpty); expect(infos.length, 2); expect(infos.getIndex(0).state!.text, "0,1,1,9"); expect(infos.getIndex(1).state!.text, "0,1,1,9,9"); }); // TODO: THIS ONE IS FAILING. FIX!!! test("Wait for a sync action that dispatches an async action which is ignored.", () async { var storeTester = createStoreTester(); storeTester.dispatch(Action12()); storeTester.dispatch(Action12()); var infos = await storeTester.waitAll( [ Action12, Action12, ], ignore: [Action13], ); expect(infos.getIndex(0).state.text, "0,12"); expect(infos.getIndex(1).state.text, "0,12,12"); }); test('Makes sure we wait until the END of all ignored actions.', () async { // var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action6()); expect(() async => await storeTester.waitAllGetLast([Action1, Action2], ignore: [Action6]), throwsA(anything)); }); test('Makes sure we wait until the END of all ignored actions.', () async { // var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action6()); TestInfo info = await (storeTester.waitAllGetLast( [ Action1, Action2, Action3, ], ignore: [Action6], )); expect(info.state!.text, "0,1,2,3"); expect(info.errors, isEmpty); storeTester.dispatch(Action6()); info = await (storeTester.waitAllGetLast([ Action6, Action1, Action2, Action3, ])); expect(info.state!.text, "0,1,2,3,6,1,2,3,6"); }); test('Makes sure we wait until the END of all ignored actions.', () async { // var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action6()); TestInfo info = await (storeTester.waitAllUnorderedGetLast( [ Action1, Action2, Action3, ], ignore: [Action6], )); expect(info.state!.text, "0,1,2,3"); expect(info.errors, isEmpty); storeTester.dispatch(Action6()); info = await (storeTester.waitAllGetLast([ Action6, Action1, Action2, Action3, ])); expect(info.state!.text, "0,1,2,3,6,1,2,3,6"); }); test('Makes sure we wait until the END of all ignored actions.', () async { // var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action6()); TestInfo info = await (storeTester.waitAllUnorderedGetLast( [ Action1, Action2, ], ignore: [Action3, Action6], )); expect(info.state!.text, "0,1,2"); expect(info.errors, isEmpty); // Now waits Action4 just to make sure Action3 hasn't leaked. storeTester.dispatch(Action4()); info = await (storeTester.waitAllGetLast( [Action4], )); expect(info.state!.text, "0,1,2,3,6,4"); expect(info.errors, isEmpty); }); test('Makes sure we wait until the END of all ignored actions.', () async { // var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action6c()); expect( () async => await storeTester .waitAllUnorderedGetLast([Action1, Action2], ignore: [Action3b, Action6c]), throwsA(StoreException("Got this unexpected action: Action4 INI."))); }); test('Error message when time is out.', () async { // var storeTester = createStoreTester(); await storeTester.waitAllUnordered([Action1], timeoutInSeconds: 1).then((_) { fail('There was no timeout.'); return null; // ignore: dead_code }, onError: expectAsync1((dynamic error) { expect(error, StoreExceptionTimeout()); })); }); // test( 'An action dispatches other actions, and one of them throws an error. ' 'Wait until that action finishes, ' 'and check the error.', () async { var storeTester = createStoreTester(); runZonedGuarded(() { storeTester.dispatch(Action10a()); }, (error, stackTrace) { expect(error, const UserException("Hello!")); }); TestInfo info = await storeTester.waitUntil(Action11a); expect(info.error, const UserException("Hello!")); expect(info.processedError, null); expect(info.state!.text, "0,1,2"); expect(info.ini, false); }); test( 'An action dispatches other actions, and one of them throws an error. ' 'Wait until that action finishes, ' 'and check the error.', () async { var storeTester = createStoreTester(); runZonedGuarded(() { storeTester.dispatch(Action10b()); }, (error, stackTrace) { expect(error, const UserException("Hello!")); }); TestInfo info = await storeTester.waitUntil(Action11b); expect(info.error, const UserException("Hello!")); expect(info.processedError, null); expect(info.state!.text, "0,1,2"); expect(info.ini, false); }); test( 'An action dispatches other actions, and one of them throws an error. ' 'Wait until that action finishes, ' 'and check the error.', () async { var storeTester = createStoreTester(); runZonedGuarded(() { storeTester.dispatch(Action10c()); }, (error, stackTrace) { expect(error, const UserException("Hello!")); }); TestInfo info = await storeTester.waitUntil(Action11b); expect(info.error, const UserException("Hello!")); expect(info.processedError, null); expect(info.state!.text, "0,1,2,3"); expect(info.ini, false); }); test( 'An action dispatches other actions, and one of them ' '(a sync one) throws an error. ' 'Wait until the error TYPE is thrown, ' 'and check the error.', () async { var storeTester = createStoreTester(); runZonedGuarded(() { storeTester.dispatch(Action10a()); }, (error, stackTrace) {}); TestInfo info = await (storeTester.waitUntilErrorGetLast( error: UserException, timeoutInSeconds: 1, )); expect(info.error, const UserException("Hello!")); expect(info.processedError, null); expect(info.state!.text, "0,1,2"); expect(info.ini, false); }); test( 'An action dispatches other actions, and one of them ' '(an async one) throws an error. ' 'Wait until the error (compare using equals) is thrown, ' 'and check the error.', () async { var storeTester = createStoreTester(); runZonedGuarded(() { storeTester.dispatch(Action10a()); }, (error, stackTrace) {}); TestInfo info = await (storeTester.waitUntilErrorGetLast( error: const UserException("Hello!"), timeoutInSeconds: 1, )); expect(info.error, const UserException("Hello!")); expect(info.processedError, null); expect(info.state!.text, "0,1,2"); expect(info.ini, false); }); test('The lastInfo can be accessed through StoreTester.lastInfo.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); var condition = (TestInfo? info) => info!.state!.text == "0,1,2"; await storeTester.waitConditionGetLast(condition); // Same as expect(info1.state.text, "0,1,2"); expect(storeTester.lastInfo.state.text, "0,1,2"); // Same as expect(info1.ini, false); expect(storeTester.lastInfo.ini, false); await storeTester.waitConditionGetLast((info) => info.state.text == "0,1,2,3,4"); // Same as expect(info2.state.text, "0,1,2,3,4"); expect(storeTester.lastInfo.state.text, "0,1,2,3,4"); // Same as expect(info1.ini, false); expect(storeTester.lastInfo.ini, false); }); test('Wait condition with testImmediately true/false.', () async { // --- // 1) If testImmediately=false, it should timeout, because it will wait until an Action // is dispatched, and after that it's not "0" anymore. var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); await storeTester .waitConditionGetLast((info) => info.state.text == "0", testImmediately: false, timeoutInSeconds: 1) .then((_) { throw AssertionError(); return null; // ignore: dead_code }, onError: expectAsync1((Object error) { expect(error, const TypeMatcher()); expect(error.toString(), "Timeout."); })); expect(storeTester.state.text, "0,1,2,3,4"); // --- // 2) If testImmediately=true, it should work, because it will test before any Action // is dispatched, and that's already "0". storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); storeTester.dispatch(Action3()); storeTester.dispatch(Action4()); TestInfo info = await (storeTester .waitConditionGetLast((info) => info.state.text == "0", timeoutInSeconds: 1)); expect(info.state!.text, "0"); expect(storeTester.state.text, "0,1,2,3,4"); // --- // 3) Let's see if the current testInfo is kept. info = await (storeTester.waitConditionGetLast((info) => info.state.text == "0,1,2,3,4", timeoutInSeconds: 1)); expect(info.state!.text, "0,1,2,3,4"); expect(storeTester.state.text, "0,1,2,3,4"); // --- // 4) Let's see if the current testInfo is kept. storeTester.dispatch(Action5()); info = await (storeTester.waitConditionGetLast((info) => info.state.text == "0,1,2,3,4", timeoutInSeconds: 1)); expect(info.state!.text, "0,1,2,3,4"); expect(storeTester.state.text, "0,1,2,3,4,5"); }); test( "Wait condition with testImmediately true " "should not see the action of previous test-infos.", () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); TestInfoList infos = await storeTester.waitCondition( (info) => info.state.text == "0,1", testImmediately: true, ); expect(infos[Action1]!.action, isA()); expect(storeTester.currentTestInfo.action, isA()); infos = await storeTester.waitCondition( (info) { if (info.action is Action1) throw AssertionError(); return true; }, testImmediately: true, ); }); test( "Wait condition with testImmediately true " "should not see the action of previous test-infos (a more realistic test).", () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); TestInfoList infos = await storeTester.waitCondition( (info) => info.state.text == "0,1", testImmediately: true, ); expect(infos, hasLength(1)); expect(infos[Action1]!.state!.text, "0,1"); expect(storeTester.currentTestInfo.action, isA()); expect(storeTester.currentTestInfo.state.text, "0,1"); storeTester.dispatch(Action2()); storeTester.dispatch(Action1()); bool hasDispatchedAction1 = false; infos = await storeTester.waitCondition( (info) { if (info.action is Action1) hasDispatchedAction1 = true; return hasDispatchedAction1 && info.state.text.contains(",2"); }, testImmediately: true, ); expect(infos, hasLength(2)); expect(infos[Action2]!.state!.text, "0,1,2"); expect(infos[Action1]!.state!.text, "0,1,2,1"); }); test('Two simultaneous store testers will receive the same state changes.', () async { var storeTester1 = createStoreTester(); var storeTester2 = StoreTester.from(storeTester1.store); expect(storeTester1.state.text, "0"); expect(storeTester2.state.text, "0"); storeTester1.dispatch(Action1()); storeTester1.dispatch(Action2()); storeTester1.dispatch(Action3()); storeTester1.dispatch(Action4()); TestInfo info1 = await (storeTester1 .waitConditionGetLast((info) => info.state.text == "0,1,2,3", timeoutInSeconds: 1)); TestInfo info2 = await (storeTester2 .waitConditionGetLast((info) => info.state.text == "0,1", timeoutInSeconds: 1)); expect(info1.state!.text, "0,1,2,3"); expect(info2.state!.text, "0,1"); expect(storeTester1.state.text, "0,1,2,3,4"); expect(storeTester2.state.text, "0,1,2,3,4"); }); test('StoreTester.dispatchState.', () async { var storeTester = createStoreTester(); expect(storeTester.state.text, "0"); storeTester.dispatch(Action1()); storeTester.dispatch(Action2()); // Remove state "1" from the stream, but not state "2". await storeTester.waitUntil(Action1); expect(storeTester.lastInfo.state.text, "0,1"); expect(storeTester.state.text, "0,1,2"); // When we dispatchState, it empties the stream. // This means state "2" will be removed. await storeTester.dispatchState(AppState("my state")); expect(storeTester.lastInfo.state.text, "my state"); expect(storeTester.state.text, "my state"); storeTester.dispatch(Action3()); expect(storeTester.state.text, "my state,3"); expect(storeTester.lastInfo.state.text, "my state"); await storeTester.waitUntil(Action3); expect(storeTester.lastInfo.state.text, "my state,3"); expect(storeTester.state.text, "my state,3"); }); } ================================================ FILE: test/store_wait_action_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Store wait action'); test('waitCondition', () async { // Returns a future that completes when the state is in the given condition. // Since the state is already in the condition, the future completes immediately. var store = Store(initialState: State(1)); await store.waitCondition((state) => state.count == 1); // If we disallow the future to complete immediately, it will throw a TimeoutException. store = Store(initialState: State(1)); expect(() => store.waitCondition((state) => state.count == 1, completeImmediately: false), throwsA(isA())); // The state is NEVER in the condition, but the timeout will end it. await expectLater( () { print('state = ${store.state}'); return store.waitCondition((state) => state.count == 2, timeoutMillis: 10); }, throwsA(isA()), ); // An ASYNC action will put the state in the condition, after a while. store = Store(initialState: State(1)); store.dispatch(IncrementActionAsync()); await store.waitCondition((state) => state.count == 2); // A SYNC action will put the state in the condition, before the condition is created. store = Store(initialState: State(1)); store.dispatch(IncrementAction()); expect(store.state.count, 2); await store.waitCondition((state) => state.count == 2); // A Future will dispatch a SYNC action that puts the state in the condition. store = Store(initialState: State(1)); Future(() => store.dispatch(IncrementAction())); expect(store.state.count, 1); await store.waitCondition((state) => state.count == 2); expect(store.state.count, 2); // A Future will dispatch a SYNC action that puts the state in the condition, after a while. store = Store(initialState: State(1)); Future.delayed(const Duration(milliseconds: 50), () => store.dispatch(IncrementAction())); expect(store.state.count, 1); await store.waitCondition((state) => state.count == 2); expect(store.state.count, 2); }); test('waitAllActions', () async { // Returns a future that completes when no actions are in progress. // Since no actions are currently in progress, the future completes immediately. // We are ACCEPTING futures completed immediately. var store = Store(initialState: State(1)); await store.waitAllActions([], completeImmediately: true); // Returns a future that completes when no actions are in progress. // Since no actions are currently in progress, the future completes immediately. // We are NOT accepting futures completed immediately: should throw a StoreException. store = Store(initialState: State(1)); await expectLater( () => store.waitAllActions([]), throwsA(isA()), ); // Returns a future that completes when no actions are in progress. // There is an actions is progress. store = Store(initialState: State(1)); store.dispatchAndWait(DelayedAction(1, delayMillis: 1)); expect(store.state.count, 1); await store.waitAllActions([]); expect(store.state.count, 2); }); test('waitActionType', () async { // Returns a future that completes when the actions of the given type is NOT in progress. // Since no actions are currently in progress, the future completes immediately. var store = Store(initialState: State(1)); await store.waitActionType(DelayedAction, completeImmediately: true); // Again, since no actions are currently in progress, the future completes immediately. // The timeout is irrelevant. store = Store(initialState: State(1)); await store.waitActionType(DelayedAction, timeoutMillis: 1, completeImmediately: true); // An actions of the given type is in progress. // But then the action ends. store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 10)); await store.waitActionType(DelayedAction); // An actions of the given type is in progress. // But the wait will timeout. store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 1000)); await expectLater( () => store.waitActionType(DelayedAction, timeoutMillis: 1), throwsA(isA()), ); }); test('waitAllActionTypes', () async { // Returns a future that completes when ALL actions of the given type are NOT in progress. // Since no actions are currently in progress, the future completes immediately. var store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 10)); store.waitAllActionTypes([DelayedAction, AnotherDelayedAction]); // An actions of the given type is in progress. // But then the action ends. store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 10)); store.waitAllActionTypes([DelayedAction, AnotherDelayedAction]); // --- // An actions of the given type is in progress. // But the wait will timeout. store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 1000)); dynamic error; try { await store.waitAllActionTypes([DelayedAction, AnotherDelayedAction], timeoutMillis: 10); } catch (_error) { error = _error; } expect(error, isA()); }); test('waitActionCondition', () async { // Returns a future that completes when the actions of the given type that are in progress // meet the given condition. Since no actions are currently in progress, and we're checking // to see if there are no actions in progress, the future completes immediately. var store = Store(initialState: State(1)); await store.waitActionCondition((actions, triggerAction) => actions.isEmpty, completeImmediately: true); }); test('waitAnyActionTypeFinishes', () async { // Returns a future that completes when ANY action of the given types finish after the // method is called. We start an action before calling the method, then call the method. // As soon as the action finishes, the future completes. var store = Store(initialState: State(1)); store.dispatch(DelayedAction(1, delayMillis: 10)); ReduxAction action = await store.waitAnyActionTypeFinishes([DelayedAction], timeoutMillis: 2000); expect(action, isA()); expect(action.status.isCompletedOk, true); // --- // Returns a future that completes when an action of ANY of the given types finish after // the method is called. We start an action before calling the method, then call the method. // As soon as the action finishes, the future completes. store = Store(initialState: State(1)); dynamic error; try { await store.waitAnyActionTypeFinishes([DelayedAction], timeoutMillis: 10); } catch (_error) { error = _error; } expect(error, isA()); }); Bdd(feature) .scenario('We dispatch no actions and wait for all to finish.') .given('No actions are dispatched.') .when('We wait until no actions are dispatched.') .then('The code continues immediately.') .run((_) async { final store = Store(initialState: State(1)); await store.waitAllActions([], completeImmediately: true); expect(store.state.count, 1); }); Bdd(feature) .scenario('We dispatch async actions and wait for all to finish.') .given('Three ASYNC actions.') .when('The actions are dispatched in PARALLEL.') .and('We wait until NO ACTIONS are being dispatched.') .then('After we wait, all actions finished.') .run((_) async { final store = Store(initialState: State(1)); expect(store.state.count, 1); store.dispatch(DelayedAction(10, delayMillis: 50)); store.dispatch(AnotherDelayedAction(100, delayMillis: 100)); store.dispatch(DelayedAction(1000, delayMillis: 20)); expect(store.state.count, 1); await store.waitAllActions([]); expect(store.state.count, 1 + 10 + 100 + 1000); }); Bdd(feature) .scenario('We dispatch an async action and wait for its action TYPE to finish.') .given('An ASYNC actions.') .when('The action is dispatched.') .then('We wait until its type finished dispatching.') .run((_) async { final store = Store(initialState: State(1)); expect(store.state.count, 1); store.dispatch(AnotherDelayedAction(123, delayMillis: 100)); store.dispatch(DelayedAction(1000, delayMillis: 10)); expect(store.state.count, 1); await store.waitActionType(DelayedAction, timeoutMillis: 2000); expect(store.state.count, 1001); await store.waitActionType(AnotherDelayedAction, timeoutMillis: 2000); expect(store.state.count, 1124); }); Bdd(feature) .scenario('We dispatch async actions and wait for some action TYPES to finish.') .given('Four ASYNC actions.') .and('The fourth takes longer than an others to finish.') .when('The actions are dispatched in PARALLEL.') .and('We wait until there the types of the faster 3 finished dispatching.') .then('After we wait, the 3 actions finished, and the fourth did not.') .run((_) async { final store = Store(initialState: State(1)); expect(store.state.count, 1); store.dispatch(DelayedAction(10, delayMillis: 50)); store.dispatch(AnotherDelayedAction(100, delayMillis: 100)); store.dispatch(YetAnotherDelayedAction(100000, delayMillis: 200)); store.dispatch(DelayedAction(1000, delayMillis: 10)); expect(store.state.count, 1); await store.waitAllActionTypes([DelayedAction, AnotherDelayedAction], timeoutMillis: 2000); expect(store.state.count, 1 + 10 + 100 + 1000); }); Bdd(feature) .scenario('We dispatch async actions and wait for some of them to finish.') .given('Four ASYNC actions.') .and('The fourth takes longer than an others to finish.') .when('The actions are dispatched in PARALLEL.') .and('We wait until there the faster 3 finished dispatching.') .then('After we wait, the 3 actions finished, and the fourth did not.') .run((_) async { final store = Store(initialState: State(1)); expect(store.state.count, 1); var action50 = DelayedAction(10, delayMillis: 50); var action100 = AnotherDelayedAction(100, delayMillis: 100); var action200 = YetAnotherDelayedAction(100000, delayMillis: 200); var action10 = DelayedAction(1000, delayMillis: 10); store.dispatch(action50); store.dispatch(action100); store.dispatch(action200); // We don't wait for this one. store.dispatch(action10); expect(store.state.count, 1); await store.waitAllActions([action50, action100, action10]); expect(store.state.count, 1 + 10 + 100 + 1000); }); } class State { final int count; State(this.count); @override String toString() { return 'State($count)'; } } class IncrementAction extends ReduxAction { @override State reduce() => State(state.count + 1); } class IncrementActionAsync extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 10)); return State(state.count + 1); } } class DelayedAction extends ReduxAction { final int increment; final int delayMillis; DelayedAction(this.increment, {required this.delayMillis}); @override Future reduce() async { await Future.delayed(Duration(milliseconds: delayMillis)); return State(state.count + increment); } } class AnotherDelayedAction extends DelayedAction { AnotherDelayedAction(int increment, {required int delayMillis}) : super(increment, delayMillis: delayMillis); } class YetAnotherDelayedAction extends DelayedAction { YetAnotherDelayedAction(int increment, {required int delayMillis}) : super(increment, delayMillis: delayMillis); } ================================================ FILE: test/store_wrap_reduce_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @immutable class AppState { final int count; AppState({this.count = 0}); AppState copy({int? count}) => AppState(count: count ?? this.count); } class _SyncAction extends ReduxAction { static int count = 0; @override AppState reduce() { count++; return state.copy(count: state.count + 1); } } class _AsyncAction extends ReduxAction { static int count = 0; @override Future reduce() async { await microtask; count++; return state.copy(count: state.count + 1); } } class _TestWrapReduce extends WrapReduce { @override AppState process({required oldState, required newState}) => newState; } void main() { late Store store; setUp(() async { store = Store( initialState: AppState(), wrapReduce: _TestWrapReduce(), ); }); group(WrapReduce, () { test("Only reduces sync reducer once.", () async { expect(store.state.count, 0); await store.dispatch(_SyncAction()); expect(_SyncAction.count, 1); expect(store.state.count, 1); }); test("Only reduces async reducer once.", () async { expect(store.state.count, 0); await store.dispatch(_AsyncAction()); expect(_AsyncAction.count, 1); expect(store.state.count, 1); }); }); } ================================================ FILE: test/sync_async_test.dart ================================================ import 'dart:async'; import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; // Developed by Marcelo Glasberg (2019) https://glasberg.dev and https://github.com/marcglasberg // For more info: https://asyncredux.com AND https://pub.dev/packages/async_redux /// This tests show that both sync and async reducers work as they should. /// Async reducers work as long as we return uncompleted futures. /// https://www.woolha.com/articles/dart-event-loop-microtask-event-queue /// https://steemit.com/utopian-io/@tensor/the-fundamentals-of-zones-microtasks-and-event-loops-in-the-dart-programming-language-dart-tutorial-part-3 void main() { test( 'This tests the mechanism of a SYNC Reducer: ' 'The reducer changes the state to A, and it will later be changed to B. ' 'It works if the reducer returns `AppState`.', () async { // var state = ""; String reduce() { Future.microtask(() => state += "A"); return "B"; } /// There is no 'A' after calling the reducer, because it ran SYNC. state = reduce(); expect(state, "B"); /// After a microtask, 'A' appears. await Future.microtask(() {}); expect(state, "BA"); }); test( 'This tests the mechanism of a ASYNC Reducer: ' 'The reducer changes the state to A, and it will later be changed to B. ' 'It works if the reducer returns a `Future` and contains the `await` keyword. ' 'This works because the `then` is called synchronously after the `return`.', () async { // var state = ""; Future reduce() async { state += "A"; Future.microtask(() => state += "B"); state += "C"; await Future.microtask(() {}); state += "D"; Future.microtask(() => state += "E"); return "F"; } /// There is no 'E' yet, because even if the reducer is async, /// the then() method ran SYNC after the value was returned. await reduce().then((newState) => state += newState); expect(state, "ACBDF"); /// After a microtask, 'E' appears. await Future.microtask(() {}); expect(state, "ACBDFE"); }); test( "Tests what happens when we do it wrong, and return COMPLETED Futures." "The reducer changes the state to A, and it should later be changed to B." "It fails if the reducer returns a Future and it does NOT contain the await keyword." "If the reducer does NOT contain the `await` keyword, it means it was created as a completed Future." "In this case, Dart schedules the `then` for the next microtask" "(see why here: https://github.com/dart-lang/sdk/issues/14323)." "In other words, in this case `then` is called asynchronously, one microtask after the `return`." "If some other process changes the state in that exact microtask the state change may be lost." "We don't allow this to happen because we check the reducer signature, and if it returns" "a Future we force it to wait for the next microtask. " "In other words, we make sure the future is uncompleted.", () async { // var state = ""; Future reduce() async { Future.microtask(() => state += "B"); return "A"; } // The reducer returned 'A', but the microtask that adds 'B' // ran before the then() method had the chance to run. await reduce().then((newState) => state += newState); expect(state, "BA"); // It's all finished by now, nothing yet to run. await Future.microtask(() {}); expect(state, "BA"); }); test( '1) A sync reducer is called, ' 'and no actions are dispatched inside of the reducer. ' 'It acts as a pure function, just like a regular reducer of "vanilla" Redux.', () async { states = []; var storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action1B()); TestInfo info = await (storeTester.waitAllUnorderedGetLast([Action1B])); expect(states, [AppState('A')]); expect(info.state!.text, 'AB'); }); test( '2) A sync reducer is called, ' 'which dispatches another sync action. ' 'They are both executed synchronously.', () async { states = []; var storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action2B()); TestInfo info = await (storeTester.waitAllUnorderedGetLast([Action2B, Action2C])); expect(states, [AppState('A'), AppState('AC')]); expect(info.state!.text, 'ACB'); }); test( '3) A sync reducer is called, ' 'which dispatches an ASYNC action.', () async { states = []; var storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action3B()); TestInfo info = await (storeTester.waitAllUnorderedGetLast([Action3B, Action3C])); expect(states, [AppState('A'), AppState('A')]); expect(info.state!.text, 'ABC'); }); test( '4) An ASYNC reducer is called, ' 'which dispatches another ASYNC action. ' 'The second reducer finishes BEFORE the first.', () async { states = []; var storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action4B()); TestInfo info = await (storeTester.waitAllUnorderedGetLast([Action4B, Action4C])); expect(states, [AppState('A'), AppState('A'), AppState('A'), AppState('AC')]); expect(info.state!.text, 'ACB'); }); test( '5) An ASYNC reducer is called, ' 'which dispatches another ASYNC action. ' 'The second reducer finishes AFTER the first.', () async { states = []; var storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action5B()); TestInfo info = await (storeTester.waitAllUnorderedGetLast([Action5B, Action5C])); expect(states, [AppState('A'), AppState('A'), AppState('A'), AppState('A')]); expect(info.state!.text, 'ABC'); }); test( "This tests the mechanism of ASYNC Reducers: " "1) Completed then Completed = state gets swallowed. " "2) Completed then Uncompleted = wrong, but works because of order. " "3) Uncompleted then Completed = state gets swallowed. " "4) Uncompleted then Uncompleted = correct and works. " "Note: " "* An async reducer that returns a COMPLETED future's will:" " - Apply the state in the very next microtask after it is dispatched." " - Apply the state in the very next microtask after the reducer returned (which is bad)." "* An async reducer that returns an UNCOMPLETED future's will:" " - Apply the state in the very next microtask after it is dispatched, or after that." " - Apply the state in the SAME microtask when the reducer returned (which is good).", () async { // // 1) Completed then Completed = state gets swallowed. var storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action6ACompleted()); storeTester.dispatch(Action6BCompleted()); var info = await (storeTester.waitAllUnordered([Action6ACompleted, Action6BCompleted])); expect(info.first.action.runtimeType, Action6ACompleted); expect(info.last.action.runtimeType, Action6BCompleted); expect(info.first.state.text, 'AX'); expect(info.last.state.text, 'A'); // The X was swallowed. // 2) Completed then Uncompleted = wrong, but works because of order. storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action6ACompleted()); storeTester.dispatch(Action6BUncompleted()); info = await storeTester.waitAllUnordered([Action6ACompleted, Action6BUncompleted]); expect(info.first.action.runtimeType, Action6ACompleted); expect(info.last.action.runtimeType, Action6BUncompleted); expect(info.first.state.text, 'AX'); expect(info.last.state.text, 'AX'); // 3) Uncompleted then Completed = state gets swallowed. storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action6AUncompleted()); storeTester.dispatch(Action6BCompleted()); info = await storeTester.waitAllUnordered([Action6AUncompleted, Action6BCompleted]); expect(info.first.action.runtimeType, Action6AUncompleted); expect(info.last.action.runtimeType, Action6BCompleted); expect(info.first.state.text, 'AX'); expect(info.last.state.text, 'A'); // The X was swallowed. // 4) Uncompleted then Uncompleted = correct and works. storeTester = StoreTester(initialState: AppState.initialState()); expect(storeTester.state.text, 'A'); storeTester.dispatch(Action6AUncompleted()); storeTester.dispatch(Action6BUncompleted()); info = await storeTester.waitAllUnordered([Action6AUncompleted, Action6BUncompleted]); expect(info.first.action.runtimeType, Action6AUncompleted); expect(info.last.action.runtimeType, Action6BUncompleted); expect(info.first.state.text, 'AX'); expect(info.last.state.text, 'AX'); }); test( "Test that if you add method assertUncompletedFuture() to the end of reducers, " "it's capable of detecting completed futures.", () async { // var storeTester = StoreTester(initialState: AppState.initialState()); // --- dynamic error1 = ""; runZonedGuarded(() async { storeTester.dispatch(Action7Completed()); }, (_error, stackTrace) { error1 = _error; }); await Future.delayed(const Duration(milliseconds: 100)); expect(error1.toString(), contains("This may result in state changes being lost")); // --- dynamic error2 = ""; runZonedGuarded(() async { storeTester.dispatch(Action7Uncompleted()); }, (_error, stackTrace) { error2 = _error; }); await Future.delayed(const Duration(milliseconds: 100)); expect(error2, ""); }); test( "Test that dispatching a sync action works just the same as calling a sync function, " "and dispatching an async action works just the same as calling an async function.", () async { // var storeTester = StoreTester(initialState: AppState.initialState()); // --- states = []; Future asyncFunction() async { states.add(AppState('f1')); await Future.microtask(() {}); states.add(AppState('f2')); } /// The below code will print: 1 3 5 2 4 6 states.add(AppState('BEFORE')); storeTester.dispatch(MyAsyncAction()); asyncFunction(); states.add(AppState('AFTER')); await Future.delayed(const Duration(milliseconds: 100)); expect(states, [ AppState('BEFORE'), AppState('a1'), AppState('f1'), AppState('AFTER'), AppState('a2'), AppState('f2'), ]); }); } // ---------------------------------------------- /// The app state, which in this case is just a text. @immutable class AppState { final String text; AppState(this.text); AppState copy(String? text) => AppState(text ?? this.text); static AppState initialState() => AppState('A'); @override bool operator ==(Object other) => identical(this, other) || other is AppState && runtimeType == other.runtimeType && text == other.text; @override int get hashCode => text.hashCode; @override String toString() => text.toString(); } late List states; // ---------------------------------------------- class Action1B extends ReduxAction { @override AppState reduce() { states.add(state); return state.copy(state.text + 'B'); } } // ---------------------------------------------- class Action2B extends ReduxAction { @override AppState reduce() { states.add(state); dispatch(Action2C()); states.add(state); return state.copy(state.text + 'B'); } } class Action2C extends ReduxAction { @override AppState reduce() { return state.copy(state.text + 'C'); } } // ---------------------------------------------- class Action3B extends ReduxAction { @override AppState reduce() { states.add(state); dispatch(Action3C()); states.add(state); return state.copy(state.text + 'B'); } } class Action3C extends ReduxAction { @override Future reduce() async { await Future.microtask(() {}); return state.copy(state.text + 'C'); } } // ---------------------------------------------- class Action4B extends ReduxAction { @override Future reduce() async { states.add(state); await Future.delayed(const Duration(milliseconds: 100)); states.add(state); dispatch(Action4C()); states.add(state); await Future.delayed(const Duration(milliseconds: 200)); states.add(state); return state.copy(state.text + 'B'); } } class Action4C extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 50)); return state.copy(state.text + 'C'); } } // ---------------------------------------------- class Action5B extends ReduxAction { @override Future reduce() async { states.add(state); await Future.delayed(const Duration(milliseconds: 100)); states.add(state); dispatch(Action5C()); states.add(state); await Future.delayed(const Duration(milliseconds: 50)); states.add(state); return state.copy(state.text + 'B'); } } class Action5C extends ReduxAction { @override Future reduce() async { await Future.delayed(const Duration(milliseconds: 200)); return state.copy(state.text + 'C'); } } // ---------------------------------------------- class Action6ACompleted extends ReduxAction { @override Future reduce() async => state.copy(state.text + 'X'); } class Action6BCompleted extends ReduxAction { @override Future reduce() async => state; } class Action6AUncompleted extends ReduxAction { @override Future reduce() async { await microtask; return state.copy(state.text + 'X'); } } class Action6BUncompleted extends ReduxAction { @override Future reduce() async { await microtask; return state; } } // ---------------------------------------------- class Action7Completed extends ReduxAction { @override Future reduce() async { assertUncompletedFuture(); return state; } } class Action7Uncompleted extends ReduxAction { @override Future reduce() async { await microtask; assertUncompletedFuture(); return state; } } // ---------------------------------------------- class MyAsyncAction extends ReduxAction { @override Future reduce() async { states.add(AppState('a1')); await microtask; states.add(AppState('a2')); return state; } } // ---------------------------------------------- ================================================ FILE: test/test_utils.dart ================================================ import 'dart:io'; /// Do not run on CI environments, like GitHub Actions. bool get isCI => Platform.environment.containsKey('CI'); ================================================ FILE: test/throttle_mixin_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:bdd_framework/bdd_framework.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { var feature = BddFeature('Throttle actions'); Bdd(feature) .scenario('Action is throttled when dispatched twice quickly') .given('An action with the Throttle mixin') .when('The action is dispatched twice within the throttle period') .then('It should only execute once') .run((_) async { // var store = Store(initialState: AppState(0)); await store.dispatch(ThrottleAction()); expect(store.state.count, 1); // Dispatch again immediately. This dispatch should be aborted. await store.dispatch(ThrottleAction()); expect(store.state.count, 1); }); Bdd(feature) .scenario('Action executes again after throttle period expires') .given('An action with the Throttle mixin') .when('The action is dispatched, ' 'then after waiting for the throttle period, dispatched again') .then('It should execute both times') .run((_) async { // var store = Store(initialState: AppState(0)); await store.dispatch(ThrottleAction()); expect(store.state.count, 1); // Wait for a bit more than the default throttle (400 ms). await Future.delayed(const Duration(milliseconds: 400)); await store.dispatch(ThrottleAction()); expect(store.state.count, 2); }); Bdd(feature) .scenario( 'Two different actions with the same lock are throttled together') .given('Two actions that override lockBuilder to return the same lock') .when('Both actions are dispatched in quick succession') .then('Only the first action should execute') .run((_) async { // var store = Store(initialState: AppState(0)); await store.dispatch(ThrottleAction1()); expect(store.state.count, 1); // ThrottleAction2 uses the same lock as ThrottleAction1. await store.dispatch(ThrottleAction2()); expect(store.state.count, 1); }); Bdd(feature) .scenario('Two different actions with the same lock execute ' 'if throttle period expires') .given('Two actions that override lockBuilder to return the same lock') .when('The first action is dispatched, ' 'throttle period passes, then the second is dispatched') .then('Both actions should execute') .run((_) async { // var store = Store(initialState: AppState(0)); await store.dispatch(ThrottleAction1()); expect(store.state.count, 1); await Future.delayed(const Duration(milliseconds: 400)); await store.dispatch(ThrottleAction2()); expect(store.state.count, 2); }); Bdd(feature) .scenario('Actions with different runtime types ' 'are not throttled together') .given('Two actions with the Throttle mixin but different runtime types') .when('Both actions are dispatched in quick succession') .then('Both should execute independently') .run((_) async { // var store = Store(initialState: AppState(0)); await store.dispatch(ThrottleActionA()); expect(store.state.count, 1); await store.dispatch(ThrottleActionB()); expect(store.state.count, 2); }); } // A simple state that holds a count. class AppState { final int count; AppState(this.count); AppState copy({int? count}) => AppState(count ?? this.count); @override String toString() => 'TestState($count)'; } // An action that uses the Throttle mixin to increment the state. class ThrottleAction extends ReduxAction with Throttle { @override int throttle = 300; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Two actions that override lockBuilder to return the same lock. class ThrottleAction1 extends ReduxAction with Throttle { @override int throttle = 300; @override Object? lockBuilder() => 'sharedLock'; @override AppState reduce() { return state.copy(count: state.count + 1); } } class ThrottleAction2 extends ReduxAction with Throttle { @override int throttle = 300; @override Object? lockBuilder() => 'sharedLock'; @override AppState reduce() { return state.copy(count: state.count + 1); } } // Two actions with default lock (their runtime types differ). class ThrottleActionA extends ReduxAction with Throttle { @override int throttle = 300; @override AppState reduce() { return state.copy(count: state.count + 1); } } class ThrottleActionB extends ReduxAction with Throttle { @override int throttle = 300; @override AppState reduce() { return state.copy(count: state.count + 1); } } ================================================ FILE: test/user_exception_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test( 'Get title and content from UserException. ' 'Note: This is already tested in async_redux_core, so no need to do much here.', () { // // UserException with no given cause. var exception = const UserException('Some msg'); var (title, content) = exception.titleAndContent(); expect(title, ''); expect(content, 'Some msg'); expect(exception.toString(), 'UserException{Some msg}'); // UserException with cause, and the cause is also an UserException. exception = const UserException('Some msg', reason: 'Other msg'); (title, content) = exception.titleAndContent(); expect(title, 'Some msg'); expect(content, 'Other msg'); expect(exception.toString(), 'UserException{Some msg|Reason: Other msg}'); // UserException with cause, and the cause is NOT an UserException. exception = const UserException('Some msg', reason: 'Other msg'); (title, content) = exception.titleAndContent(); expect(title, 'Some msg'); expect(content, 'Other msg'); expect(exception.toString(), 'UserException{Some msg|Reason: Other msg}'); }); test('Adding callbacks', () { // String result = ''; var exception = const UserException('Some msg') // .addCallbacks(onOk: () => result += 'a', onCancel: () => result += 'b'); expect(exception.onOk, isNotNull); expect(exception.onCancel, isNotNull); expect(result, ''); exception.onOk?.call(); expect(result, 'a'); exception.onCancel?.call(); expect(result, 'ab'); }); test('Adding properties', () { // var exception = const UserException('Some msg').addProps({'1': 'a', '2': 'b'}); expect(exception.reason, isNull); expect(exception.hardCause, isNull); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, {'1': 'a', '2': 'b'}.lock); exception = exception.addProps({'2': 'c', '3': 'd'}); expect(exception.props, {'1': 'a', '2': 'c', '3': 'd'}.lock); exception = exception.addProps(null); exception = exception.addProps({}); expect(exception.props, {'1': 'a', '2': 'c', '3': 'd'}.lock); }); test('Adding hard cause', () { // // 1) The cause is not null, String, or UserException. var exception = const UserException('Some msg') // .addCause(const FormatException('Some other msg')); expect(exception.reason, isNull); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); // 2) Another hard cause will replace a previous one. exception = exception.addCause(UnsupportedError('Yet another')); expect(exception.reason, isNull); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); // 3) string will add to the reason. exception = exception.addCause('Some text'); expect(exception.reason, 'Some text'); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); // 4) A string will add to (not replace) the reason. exception = exception.addCause('Yet another text'); expect(exception.reason, 'Some text\n\nReason: Yet another text'); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); // 5) A UserException will add to (not replace) the reason. exception = exception.addCause(const UserException('My exception')); expect(exception.reason, 'Some text\n\nReason: Yet another text\n\nReason: My exception'); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); // 6) A UserException with a reason will add to (not replace) the reason. exception = exception.addCause(const UserException('Another exception', reason: 'My reason')); expect( exception.reason, 'Some text\n\nReason: Yet another text\n\nReason: My exception\n\n' 'Reason: Another exception\n\nReason: My reason'); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); // 6) Adding null as a cause doesn't change anything. exception = exception.addCause(null); expect( exception.reason, 'Some text\n\nReason: Yet another text\n\nReason: My exception\n\n' 'Reason: Another exception\n\nReason: My reason'); expect(exception.hardCause, isA()); expect(exception.onOk, isNull); expect(exception.onCancel, isNull); expect(exception.props, isEmpty); }); } ================================================ FILE: test/view_model_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter_test/flutter_test.dart'; class MyObjPlain { @override bool operator ==(Object other) => identical(this, other) || // other is MyObjPlain && runtimeType == other.runtimeType; @override int get hashCode => 0; } class MyObjVmEquals extends VmEquals { @override bool operator ==(Object other) => identical(this, other) || // other is MyObjVmEquals && runtimeType == other.runtimeType; @override int get hashCode => 0; } class ViewModel extends Vm { final String name; final int age; final dynamic myObj; ViewModel(this.name, this.age, this.myObj) : super(equals: [ name, age, myObj, ]); } void main() { test('Vm equality.', () { // // Comparison by equality. Same object. dynamic myObj = MyObjPlain(); var vm1 = ViewModel("John", 35, myObj); var vm2 = ViewModel("John", 35, myObj); expect(vm1 == vm2, isTrue); // Comparison by equality. Different objects. vm1 = ViewModel("John", 35, MyObjPlain()); vm2 = ViewModel("John", 35, MyObjPlain()); expect(vm1 == vm2, isTrue); // --- // Now we're going to use a VmEquals object: // Same by equality, but Different by vmEquals(). expect(MyObjVmEquals() == MyObjVmEquals(), isTrue); expect(MyObjVmEquals().vmEquals(MyObjVmEquals()), isFalse); // Comparison by identity. Same object. myObj = MyObjVmEquals(); vm1 = ViewModel("John", 35, myObj); vm2 = ViewModel("John", 35, myObj); expect(vm1 == vm2, isTrue); // Comparison by identity. Different objects. vm1 = ViewModel("John", 35, MyObjVmEquals()); vm2 = ViewModel("John", 35, MyObjVmEquals()); expect(vm1 != vm2, isTrue); }); } ================================================ FILE: test/wait_action_test.dart ================================================ import 'package:async_redux/async_redux.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; late Store store; @immutable class AppState { final Wait wait; AppState({this.wait = Wait.empty}); AppState copy({Wait? wait}) => AppState(wait: wait ?? this.wait); } // This simulates using the Freezed package. class AppStateFreezed { final Wait wait; AppStateFreezed({this.wait = Wait.empty}); AppStateFreezed copyWith({Wait? wait}) => AppStateFreezed(wait: wait ?? this.wait); } // This simulates using the BuiltValue package. class AppStateBuiltValue { Wait wait; AppStateBuiltValue({this.wait = Wait.empty}); AppStateBuiltValue rebuild(dynamic func(dynamic state)) => func(AppStateBuiltValue(wait: Wait())); } class MyAction {} class MyAction1 {} class MyAction2 {} void main() { setUp(() async { store = Store(initialState: AppState(wait: Wait())); }); test("Wait class is immutable. Empty object is always the same instance.", () { var wait1 = Wait(); var wait2 = wait1.add(flag: "x"); expect(wait1, isNot(wait2)); var wait3 = wait2.remove(flag: "x"); expect(wait3, wait1); expect(wait3, isNot(wait2)); var wait4 = wait2.clear(); expect(wait4, wait1); expect(wait4, isNot(wait2)); expect(wait4, wait3); }); test("Waiting for some action to finish.", () { var action = MyAction(); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action), false); expect(store.state.wait.isWaitingForType(), false); store.dispatch(WaitAction.add(action)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action), true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.remove(action)); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action), false); expect(store.state.wait.isWaitingForType(), false); }); test("Waiting for some action type to finish.", () { var action1a = MyAction1(); var action1b = MyAction1(); var action2 = MyAction2(); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action1a), false); expect(store.state.wait.isWaiting(action2), false); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), false); store.dispatch(WaitAction.add(action1a)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1a), true); expect(store.state.wait.isWaiting(action1b), false); expect(store.state.wait.isWaiting(action2), false); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), true); expect(store.state.wait.isWaitingForType(), false); store.dispatch(WaitAction.add(action1b)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1a), true); expect(store.state.wait.isWaiting(action1b), true); expect(store.state.wait.isWaiting(action2), false); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), true); expect(store.state.wait.isWaitingForType(), false); store.dispatch(WaitAction.add(action2)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1a), true); expect(store.state.wait.isWaiting(action1b), true); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.remove(action1a)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1a), false); expect(store.state.wait.isWaiting(action1b), true); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.remove(action1b)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1a), false); expect(store.state.wait.isWaiting(action1b), false); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), false); expect(store.state.wait.isWaitingForType(), true); }); test("Waiting for 2 actions to finish.", () { var action1 = MyAction(); var action2 = MyAction(); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action1), false); expect(store.state.wait.isWaiting(action2), false); expect(store.state.wait.isWaitingForType(), false); store.dispatch(WaitAction.add(action1)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1), true); expect(store.state.wait.isWaiting(action2), false); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.add(action2)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1), true); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.remove(action1)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1), false); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.remove(action2)); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action1), false); expect(store.state.wait.isWaiting(action2), false); expect(store.state.wait.isWaitingForType(), false); }); test("Clear the waiting (everything).", () { var action = MyAction(); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action), false); expect(store.state.wait.isWaitingForType(), false); store.dispatch(WaitAction.add(action)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.clear()); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action), false); expect(store.state.wait.isWaitingForType(), false); }); test("Clear the waiting (a specific flag).", () { var action1 = MyAction(); store.dispatch(WaitAction.add(action1, ref: "X")); store.dispatch(WaitAction.add(action1, ref: "Y")); var action2 = MyAction(); store.dispatch(WaitAction.add(action2, ref: "X")); store.dispatch(WaitAction.add(action2, ref: "A")); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1), true); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), true); store.dispatch(WaitAction.clear(action1)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action1), false); expect(store.state.wait.isWaiting(action2), true); expect(store.state.wait.isWaitingForType(), true); }); test("Waiting for some action with ref and ref.", () { var action = MyAction(); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action), false); store.dispatch(WaitAction.add(action, ref: 123)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action), true); store.dispatch(WaitAction.add(action, ref: 456)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action, ref: 123), true); expect(store.state.wait.isWaiting(action, ref: 456), true); expect(store.state.wait.isWaiting(action, ref: 789), false); expect(store.state.wait.isWaiting(action), true); /// Removing ref without ref removes ref (ignores subRefs). store.dispatch(WaitAction.remove(action)); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action, ref: 123), false); expect(store.state.wait.isWaiting(action, ref: 456), false); expect(store.state.wait.isWaiting(action, ref: 789), false); expect(store.state.wait.isWaiting(action), false); // --- // Now try again, removing ref by ref. action = MyAction(); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action), false); store.dispatch(WaitAction.add(action, ref: 123)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action), true); store.dispatch(WaitAction.add(action, ref: 456)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action), true); /// Removing ref with ref removes just the ref (until all are removed). store.dispatch(WaitAction.remove(action, ref: 123)); expect(store.state.wait.isWaitingAny, true); expect(store.state.wait.isWaiting(action, ref: 123), false); expect(store.state.wait.isWaiting(action, ref: 456), true); expect(store.state.wait.isWaiting(action, ref: 789), false); expect(store.state.wait.isWaiting(action), true); store.dispatch(WaitAction.remove(action, ref: 456)); expect(store.state.wait.isWaitingAny, false); expect(store.state.wait.isWaiting(action, ref: 123), false); expect(store.state.wait.isWaiting(action, ref: 456), false); expect(store.state.wait.isWaiting(action, ref: 789), false); expect(store.state.wait.isWaiting(action), false); }); test("Test compatibility with the Freezed package.", () { Store freezedStore; freezedStore = Store(initialState: AppStateFreezed(wait: Wait())); var action = MyAction(); expect(freezedStore.state.wait.isWaitingAny, false); expect(freezedStore.state.wait.isWaiting(action), false); freezedStore.dispatch(WaitAction.add(action)); expect(freezedStore.state.wait.isWaitingAny, true); expect(freezedStore.state.wait.isWaiting(action), true); freezedStore.dispatch(WaitAction.remove(action)); expect(freezedStore.state.wait.isWaitingAny, false); expect(freezedStore.state.wait.isWaiting(action), false); }); test("Test compatibility with the BuiltValue package.", () { Store builtValueStore; builtValueStore = Store(initialState: AppStateBuiltValue(wait: Wait())); var action = MyAction(); expect(builtValueStore.state.wait.isWaitingAny, false); expect(builtValueStore.state.wait.isWaiting(action), false); builtValueStore.dispatch(WaitAction.add(action)); expect(builtValueStore.state.wait.isWaitingAny, true); expect(builtValueStore.state.wait.isWaiting(action), true); builtValueStore.dispatch(WaitAction.remove(action)); expect(builtValueStore.state.wait.isWaitingAny, false); expect(builtValueStore.state.wait.isWaiting(action), false); }); test("Test compatibility with the BuiltValue package.", () { Store freezedStore; freezedStore = Store(initialState: AppStateFreezed(wait: Wait())); var action = MyAction(); expect(freezedStore.state.wait.isWaitingAny, false); expect(freezedStore.state.wait.isWaiting(action), false); freezedStore.dispatch(WaitAction.add(action)); expect(freezedStore.state.wait.isWaitingAny, true); expect(freezedStore.state.wait.isWaiting(action), true); freezedStore.dispatch(WaitAction.remove(action)); expect(freezedStore.state.wait.isWaitingAny, false); expect(freezedStore.state.wait.isWaiting(action), false); }); }