diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index e9b5728..5069ea9 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -3,11 +3,11 @@ description: Sets up the Flutter environment inputs: flutter-version: - description: 'The version of Flutter to use' + description: "The version of Flutter to use" required: false - default: '3.24.3' + default: "3.38.0" pub-cache: - description: 'The name of the pub cache variable' + description: "The name of the pub cache variable" required: false default: control @@ -32,7 +32,7 @@ runs: - name: 🚂 Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '${{ inputs.flutter-version }}' + flutter-version: "${{ inputs.flutter-version }}" channel: "stable" - name: 📤 Restore Pub modules diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 77ad758..5caaacc 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -43,7 +43,7 @@ jobs: - name: 🚂 Setup Flutter and dependencies uses: ./.github/actions/setup with: - flutter-version: 3.24.3 + flutter-version: 3.38.0 - name: 👷 Install Dependencies timeout-minutes: 1 @@ -76,7 +76,7 @@ jobs: - name: 📥 Upload test report uses: actions/upload-artifact@v4 - if: (success() || failure()) && ${{ github.actor != 'dependabot[bot]' }} + if: ${{ (success() || failure()) && github.actor != 'dependabot[bot]' }} with: name: test-results path: reports/tests.json diff --git a/.gitignore b/.gitignore index 1fcaad7..78011ac 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ log.pana.json coverage/ /test/**/*.json /test/.test_coverage.dart +reports/ # Temp /tmp @@ -50,4 +51,7 @@ coverage/ .fvm/flutter_sdk # Generated files -*.*.dart \ No newline at end of file +*.*.dart + +# LLMS +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d8eec..3967c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ +## 1.0.0-dev.1 + +### Features + +- **ADDED**: Generic `handle()` method - now supports return values + - Example: `Future fetchUser(String id) => handle(() async { ... return user; })` + - Type-safe return values from controller operations + - Works with all concurrency strategies (sequential, concurrent, droppable) + +### Breaking Changes + +- **REMOVED**: `base` modifier from `Controller`, `StateController`, and concurrency handler mixins + - Controllers no longer require `final` or `base` in user code + - Provides more flexibility for inheritance patterns +- **CHANGED**: `Controller.handle()` now has a default concurrent implementation + - No longer abstract - controllers can be used without choosing a concurrency mixin + - Operations execute concurrently by default + - Provides zone for error catching, HandlerContext, observer notifications, and callbacks +- **CHANGED**: Concurrency handler mixins simplified to wrap `super.handle()` with `Mutex` + - `SequentialControllerHandler` - wraps handle with mutex for sequential execution + - `DroppableControllerHandler` - wraps handle with mutex and drops new operations if busy + - `ConcurrentControllerHandler` - **deprecated** (base behavior is already concurrent) +- **CHANGED**: `Controller.handle()` signature now includes `error` and `done` callbacks + - Before: `handle(handler, {name, meta})` + - After: `handle(handler, {error, done, name, meta})` +- **CHANGED**: `DroppableControllerHandler` now returns `null` when dropping operations + - Handle method returns `Future` to support nullable return values + - Dropped operations return `null` instead of throwing errors + - This is the expected behavior for droppable operations + +### Added + +- **ADDED**: Base `handle()` implementation in `Controller` class + - Concurrent execution by default + - Zone-based error catching (including unawaited futures) + - HandlerContext for debugging + - Observer notifications + - Error and done callbacks +- **ADDED**: `Mutex` is now the core primitive for concurrency control + - Can be used directly in controllers for custom concurrency patterns + - Sequential and droppable mixins built on top of Mutex + +### Improved + +- **IMPROVED**: Reduced code complexity + - Concurrency handler mixins reduced from ~300 lines to ~90 lines + - Removed `_ControllerEventQueue` class (no longer needed) + - Simplified architecture easier to understand and maintain +- **IMPROVED**: More flexibility in concurrency control + - Use mixins for simple cases (sequential/droppable) + - Use Mutex directly for custom patterns + - Mix and match strategies in the same controller + +### Migration Guide + +See [MIGRATION.md](MIGRATION.md) for detailed migration instructions from 0.x to 1.0.0. + +**Quick migration:** +- Remove `base` from your controller classes +- `ConcurrentControllerHandler` can be removed (controllers are concurrent by default) +- `SequentialControllerHandler` and `DroppableControllerHandler` work the same way +- For custom concurrency, use `Mutex` directly instead of creating custom mixins + ## 0.2.0 - **ADDED**: `HandlerContext` to handlers, available at zone and observer. diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..674709a --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,270 @@ +# Future Improvements and Ideas + +This document contains ideas and recommendations for future improvements to the Control library. + +## Architecture Improvements + +### Phase 1: MVP (Implemented in v1.0.0) + +#### 1. Remove `base` Modifiers ✅ +**Problem:** The `base` modifier forces users to use `final` or `base` on their controller classes, which creates unnecessary restrictions. + +**Solution:** Remove `base` from: +- `Controller` class +- `StateController` class +- Concurrency handler mixins + +**Benefits:** +- More flexibility for users +- No forced inheritance patterns +- Simpler API + +#### 2. Base `handle()` Implementation ✅ +**Problem:** Currently `handle()` is abstract in `Controller`, forcing users to choose a concurrency strategy via mixins. + +**Solution:** Implement base `handle()` in `Controller` with concurrent behavior by default. The method provides: +- Zone for error catching (including unawaited futures) +- HandlerContext for debugging +- Observer notifications +- error/done callbacks +- isProcessing tracking + +**Benefits:** +- No forced mixin selection +- Concurrent by default (most common case) +- Mixins become optional + +#### 3. Simplify Concurrency Handler Mixins ✅ +**Problem:** Current mixins have ~100 lines each with duplicated error handling logic. + +**Solution:** Make mixins simple wrappers around `super.handle()` + `Mutex`: + +```dart +mixin SequentialControllerHandler on Controller { + final Mutex _$mutex = Mutex(); + + @override + Future handle(...) => + _$mutex.synchronize(() => super.handle(...)); +} +``` + +**Benefits:** +- Reduces code from ~300 lines to ~90 lines +- Eliminates duplication +- Mixins are now just 10-15 lines each +- Easier to understand and maintain + +### Phase 2: Enhancements + +#### 4. Generic `handle()` ✅ **(Implemented in v1.0.0-dev.1)** +**Status:** Implemented + +**Implementation:** + +```dart +Future handle(Future Function() handler, {...}); + +// Usage +Future fetchUser(String id) => handle(() async { + final user = await api.getUser(id); + setState(state.copyWith(user: user)); + return user; // Can return values! +}); +``` + +**Benefits:** +- ✅ More flexible API +- ✅ Better composition +- ✅ Type-safe return values +- ✅ Works with all concurrency strategies + +**Breaking Change:** `DroppableControllerHandler` now throws `StateError` when operations are dropped instead of silently completing. + +#### 5. `tryLock()` Method for Mutex ⭐ +**Proposal:** Add non-blocking lock attempt: + +```dart +abstract class Mutex { + /// Attempts to acquire lock without waiting + /// Returns unlock function if successful, null if already locked + void Function()? tryLock(); +} + +// Usage - droppable pattern without mixin +void operation() { + final unlock = _mutex.tryLock(); + if (unlock == null) return; // Already running, drop + try { + // critical section + } finally { + unlock(); + } +} +``` + +**Benefits:** +- Enables droppable pattern without mixin +- More control over locking behavior +- No waiting if lock unavailable + +#### 6. `isIdle` Getter ⭐ +**Proposal:** Add convenience getter: + +```dart +abstract class Controller { + bool get isProcessing; + bool get isIdle => !isProcessing; // Opposite of isProcessing +} +``` + +**Benefits:** +- More readable in UI code +- Natural language + +#### 7. Extension Methods 💡 +**Proposal:** Add convenience extensions: + +```dart +extension MutexControllerExtension on Mutex { + Future handleWith( + Controller controller, + Future Function() handler, {...} + ) => synchronize(() => controller.handle(handler, ...)); +} + +// Usage +void operation() => _mutex.handleWith(this, () async {...}); +``` + +**Benefits:** +- Shorter, more readable code +- Composable helpers + +#### 8. Debounce/Throttle Utilities 💡 +**Proposal:** Add common patterns: + +```dart +class ControllerUtils { + static Future Function() debounce( + Duration duration, + Future Function() action, + ) {...} + + static Future Function() throttle( + Duration duration, + Future Function() action, + ) {...} +} + +// Usage +class SearchController extends StateController { + late final _debouncedSearch = ControllerUtils.debounce( + const Duration(milliseconds: 300), + _performSearch, + ); + + void search(String query) => handle(_debouncedSearch); +} +``` + +**Benefits:** +- Common use cases covered +- Less boilerplate +- Reusable patterns + +### Phase 3: Polish (Future) + +#### 9. Integration Tests +Add comprehensive integration tests for: +- Concurrent behavior +- Sequential behavior +- Droppable behavior +- Mixed strategies +- Error handling across all strategies + +#### 10. Performance Benchmarks +Add benchmarks comparing: +- Different concurrency strategies +- Mutex implementations +- Handler overhead + +#### 11. Enhanced Documentation +- Add dartdoc examples to all public APIs +- Create cookbook with common patterns +- Add diagrams showing concurrency flows +- Video tutorials + +#### 12. Performance Optimizations +- Cache `isProcessing` flag in sequential handler +- Optimize zone creation +- Consider object pooling for handler contexts + +## API Stability + +### Stable APIs (keep unchanged) +- `StateController.state` +- `StateController.setState()` +- `Controller.addListener()` +- `Controller.dispose()` +- `Mutex.lock()` +- `Mutex.synchronize()` + +### Evolving APIs (may change) +- Handler mixins (simplified in 1.0.0) +- `handle()` signature (may become generic) +- Observer interface (may add more hooks) + +## Breaking Changes for 1.0.0 + +### Removed +- `base` modifiers on classes and mixins + +### Changed +- `Controller.handle()` now has a default implementation (concurrent) +- Concurrency handler mixins simplified (now just wrap `super.handle()` + mutex) +- `ConcurrentControllerHandler` is now redundant (base behavior) + +### Migration +See MIGRATION.md for detailed migration guide from 0.x to 1.0.0 + +## Naming Considerations + +### Current Names (Keep) +- `handle()` - short, clear, conventional +- `Controller` - standard pattern name +- `StateController` - descriptive +- `Mutex` - well-known CS term + +### Potential Alternatives (Not Recommended) +- `handle()` → `execute()` / `runHandler()` - too verbose +- `Controller` → `Bloc` / `Manager` - different patterns +- `Mutex` → `Lock` - less precise + +## Questions for Community + +1. Should `handle()` be generic `` or stay ``? +2. Is `tryLock()` needed or is checking `locked` sufficient? +3. Should debounce/throttle be in core or separate package? +4. What other concurrency patterns are needed? (semaphore, rwlock, etc.) + +## References + +- [Mutex tests](test/unit/mutex_test.dart) - comprehensive test coverage +- [Controller tests](test/unit/state_controller_test.dart) - concurrency tests +- [Example app](example/lib/main.dart) - real-world usage + +## Contributing + +Feel free to: +- Open issues discussing these ideas +- Submit PRs implementing phase 2/3 features +- Share your use cases and patterns +- Suggest new ideas + +--- + +**Last Updated:** 2026-02-06 +**Status:** +- Phase 1 (MVP) - ✅ Implemented in v1.0.0-dev.1 +- Phase 2 Item 4 (Generic handle) - ✅ Implemented in v1.0.0-dev.1 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..fc91cfa --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,348 @@ +# Migration Guide: 0.x to 1.0.0 + +This guide will help you migrate from Control 0.x to 1.0.0. + +## Overview of Changes + +Version 1.0.0 introduces significant architectural improvements: + +1. **Removed `base` modifiers** - More flexibility in inheritance +2. **Default concurrent behavior** - No forced mixin selection +3. **Simplified mixins** - Built on top of Mutex +4. **New `handle()` signature** - Added `error` and `done` callbacks + +## Breaking Changes + +### 1. Remove `base` from Controller Classes + +**Before (0.x):** +```dart +final class MyController extends StateController + with SequentialControllerHandler { + // ... +} +``` + +**After (1.0.0):** +```dart +class MyController extends StateController + with SequentialControllerHandler { + // ... +} +``` + +**Why:** The `base` modifier forced users to use `final` or `base` on their controller classes. Removing it provides more flexibility. + +**Action Required:** Remove `base` or `final` modifiers from your controller class declarations if you want more flexibility. You can keep `final` if you prefer. + +### 2. Remove ConcurrentControllerHandler Mixin + +**Before (0.x):** +```dart +class MyController extends StateController + with ConcurrentControllerHandler { + void operation() => handle(() async { ... }); +} +``` + +**After (1.0.0):** +```dart +class MyController extends StateController { + // Concurrent by default - no mixin needed! + void operation() => handle(() async { ... }); +} +``` + +**Why:** The base `Controller` class now provides concurrent behavior by default. The mixin is redundant. + +**Action Required:** Remove `with ConcurrentControllerHandler` from your controller declarations. The mixin is deprecated but still available for backwards compatibility. + +### 3. Handle Generic Return Values + +The `handle()` method is now generic and can return values: + +**Before (0.x):** +```dart +void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); + // No return value +}); +``` + +**After (1.0.0):** +```dart +// Still works without return value +void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); +}); + +// NEW: Can now return values +Future fetchUser(String id) => handle(() async { + final user = await api.getUser(id); + setState(state.copyWith(user: user)); + return user; // Type-safe return value! +}); +``` + +**Action Required:** None for existing code. This is backward compatible. + +### 4. DroppableControllerHandler with Generic Return Values + +**Before (0.x):** +```dart +// Dropped operations completed with Future.value() +await controller.operation(); // void +``` + +**After (1.0.0):** +```dart +// Dropped operations return null with Future.value(null) +final result = await controller.operation(); // null if dropped +if (result != null) { + // Operation completed successfully + print('Result: $result'); +} else { + // Operation was dropped + print('Operation dropped: controller is busy'); +} +``` + +**Action Required:** None for existing code. The behavior is backward compatible - dropped operations return `null` (expected behavior). If you need to distinguish between dropped operations and successful operations, check for `null` return values. + +### 5. Update handle() Calls with Error Handling + +The `handle()` method signature has been extended: + +**Before (0.x):** +```dart +void operation() => handle( + () async { ... }, + name: 'operation', + meta: {'key': 'value'}, +); +``` + +**After (1.0.0):** +```dart +void operation() => handle( + () async { ... }, + error: (error, stackTrace) async { + // Optional: handle errors + }, + done: () async { + // Optional: cleanup or completion logic + }, + name: 'operation', + meta: {'key': 'value'}, +); +``` + +**Why:** The new parameters were always available in mixins but not in the base interface. Now they're standardized. + +**Action Required:** +- No action required - `error` and `done` are optional +- If you were using custom error handling in mixins, you can now use it in all controllers +- Update your code if you want to use the new error handling capabilities + +## Migration Scenarios + +### Scenario 1: Simple Concurrent Controller + +**Before:** +```dart +final class MyController extends StateController + with ConcurrentControllerHandler { + MyController() : super(initialState: MyState.initial()); + + void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); + }); +} +``` + +**After:** +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); + }); +} +``` + +**Changes:** +- Removed `final` modifier +- Removed `with ConcurrentControllerHandler` + +### Scenario 2: Sequential Controller + +**Before:** +```dart +final class MyController extends StateController + with SequentialControllerHandler { + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +**After:** +```dart +class MyController extends StateController + with SequentialControllerHandler { + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +**Changes:** +- Only removed `final` modifier (optional) +- Mixin works the same way + +### Scenario 3: Droppable Controller + +**Before:** +```dart +final class MyController extends StateController + with DroppableControllerHandler { + void operation() => handle(() async { ... }); +} +``` + +**After:** +```dart +class MyController extends StateController + with DroppableControllerHandler { + void operation() => handle(() async { ... }); +} +``` + +**Changes:** +- Only removed `final` modifier (optional) +- Mixin works the same way + +### Scenario 4: Mixed Concurrency Patterns + +**New in 1.0.0:** You can now use Mutex directly for custom patterns: + +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + final _criticalMutex = Mutex(); + final _batchMutex = Mutex(); + + // Sequential critical operations + void saveToDB() => _criticalMutex.synchronize( + () => handle(() async { + // Critical database operation + }), + ); + + // Sequential batch operations + void processBatch() => _batchMutex.synchronize( + () => handle(() async { + // Batch processing + }), + ); + + // Concurrent queries (default behavior) + void fetchData() => handle(() async { + // Fast concurrent query + }); + + // Droppable UI actions + void onButtonTap() { + final unlock = _criticalMutex.tryLock(); + if (unlock == null) return; // Already running, drop + + handle(() async { + // Handle button tap + }).whenComplete(unlock); + } +} +``` + +## Step-by-Step Migration Checklist + +1. **Update dependency in pubspec.yaml:** + ```yaml + dependencies: + control: ^1.0.0 + ``` + +2. **Run Flutter pub get:** + ```bash + flutter pub get + ``` + +3. **Find all controllers:** + ```bash + grep -r "extends.*Controller" lib/ + ``` + +4. **For each controller:** + - [ ] Remove `base` or `final` modifier (optional but recommended) + - [ ] Remove `with ConcurrentControllerHandler` if present + - [ ] Keep `with SequentialControllerHandler` or `with DroppableControllerHandler` + - [ ] Consider using Mutex directly for complex scenarios + +5. **Run tests:** + ```bash + flutter test + ``` + +6. **Check for deprecation warnings:** + ```bash + flutter analyze + ``` + +## Common Issues and Solutions + +### Issue 1: "The member 'handle' overrides an inherited member" + +**Solution:** This is just an informational message. The code works correctly. The IDE may show this while it updates its cache. + +### Issue 2: "ConcurrentControllerHandler is deprecated" + +**Solution:** Remove `with ConcurrentControllerHandler` from your controller. Concurrent is now the default behavior. + +### Issue 3: Tests failing after migration + +**Solution:** +- Ensure all test controllers are updated +- Check if tests rely on specific timing - concurrent behavior may differ +- Update mocks if using custom mixins + +## Benefits After Migration + +After migrating to 1.0.0, you'll benefit from: + +1. **More flexibility** - No forced `final` modifier +2. **Cleaner code** - No redundant `ConcurrentControllerHandler` +3. **Better control** - Use Mutex directly for custom patterns +4. **Simpler architecture** - Less boilerplate, easier to understand +5. **Same guarantees** - All existing tests pass + +## Need Help? + +If you encounter issues during migration: + +1. Check the [examples](example/) directory for updated patterns +2. Review [IDEAS.md](IDEAS.md) for architecture explanation +3. Open an issue on [GitHub](https://github.com/PlugFox/control/issues) + +## Rollback + +If you need to rollback to 0.x: + +```yaml +dependencies: + control: ^0.2.0 +``` + +Then restore your `base`/`final` modifiers and `ConcurrentControllerHandler` mixins. diff --git a/README.md b/README.md index e602e77..730aadd 100644 --- a/README.md +++ b/README.md @@ -7,43 +7,557 @@ [![Linter](https://img.shields.io/badge/style-linter-40c4ff.svg)](https://pub.dev/packages/linter) [![GitHub stars](https://img.shields.io/github/stars/plugfox/control?style=social)](https://github.com/plugfox/control/) +A simple, flexible state management library for Flutter with built-in concurrency support. + --- +## Features + +- 🎯 **Simple API** - Easy to learn and use +- 🔄 **Flexible Concurrency** - Sequential, concurrent, or droppable operation handling +- 🛡️ **Type Safe** - Full type safety with Dart's type system +- 🔍 **Observable** - Built-in observer for debugging and logging +- 🧪 **Well Tested** - Comprehensive test coverage +- 📦 **Lightweight** - Minimal dependencies +- 🔧 **Customizable** - Use Mutex for custom concurrency patterns + ## Installation Add the following dependency to your `pubspec.yaml` file: ```yaml dependencies: - control: + control: ^1.0.0 ``` -## Example +## Quick Start + +### Basic Example ```dart -/// Counter state for [CounterController] +/// Counter state typedef CounterState = ({int count, bool idle}); -/// Counter controller -final class CounterController extends StateController - with SequentialControllerHandler { +/// Counter controller - concurrent by default +class CounterController extends StateController { CounterController({CounterState? initialState}) : super(initialState: initialState ?? (idle: true, count: 0)); - void add(int value) => handle(() async { + void increment() => handle(() async { setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count + value)); + await Future.delayed(const Duration(milliseconds: 500)); + setState((idle: true, count: state.count + 1)); }); - void subtract(int value) => handle(() async { + void decrement() => handle(() async { setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count - value)); + await Future.delayed(const Duration(milliseconds: 500)); + setState((idle: true, count: state.count - 1)); }); } ``` +## Concurrency Strategies + +### 1. Concurrent (Default) + +Operations execute in parallel without waiting for each other: + +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + // These operations run concurrently + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +### 2. Sequential (with Mixin) + +Operations execute one after another in FIFO order: + +```dart +class MyController extends StateController + with SequentialControllerHandler { + MyController() : super(initialState: MyState.initial()); + + // These operations run sequentially + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +### 3. Droppable (with Mixin) + +New operations are dropped if one is already running: + +```dart +class MyController extends StateController + with DroppableControllerHandler { + MyController() : super(initialState: MyState.initial()); + + // If operation1 is running, operation2 is dropped + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +### 4. Custom (with Mutex) + +Use `Mutex` directly for fine-grained control: + +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + final _criticalMutex = Mutex(); + final _batchMutex = Mutex(); + + // Sequential critical operations + void criticalOperation() => _criticalMutex.synchronize( + () => handle(() async { ... }), + ); + + // Sequential batch operations (different queue) + void batchOperation() => _batchMutex.synchronize( + () => handle(() async { ... }), + ); + + // Concurrent fast operations + void fastOperation() => handle(() async { ... }); +} +``` + +## Return Values from Operations + +The `handle()` method is generic and can return values: + +```dart +class UserController extends StateController { + UserController(this.api) : super(initialState: UserState.initial()); + + final UserApi api; + + /// Fetch user and return the user object + Future fetchUser(String id) => handle(() async { + final user = await api.getUser(id); + setState(state.copyWith(user: user, loading: false)); + return user; // Type-safe return value + }); + + /// Update user and return success status + Future updateUser(User user) => handle(() async { + try { + await api.updateUser(user); + setState(state.copyWith(user: user)); + return true; + } catch (e) { + return false; + } + }); +} + +// Usage +final user = await controller.fetchUser('123'); +print('Fetched: ${user.name}'); + +final success = await controller.updateUser(updatedUser); +if (success) { + print('User updated successfully'); +} +``` + +**Note:** With `DroppableControllerHandler`, dropped operations return `null` instead of executing. + +## Usage in Flutter + +### Inject Controller + +Use `ControllerScope` to provide controller to widget tree: + +```dart +class App extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialApp( + home: ControllerScope( + CounterController.new, + child: const CounterScreen(), + ), + ); +} +``` + +### Consume State + +Use `StateConsumer` to rebuild widgets when state changes: + +```dart +class CounterScreen extends StatelessWidget { + @override + Widget build(BuildContext context) => Scaffold( + body: StateConsumer( + builder: (context, state, _) => Text('Count: ${state.count}'), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.controllerOf().increment(), + child: Icon(Icons.add), + ), + ); +} +``` + +### Use ValueListenable + +Convert state to `ValueListenable` for granular updates: + +```dart +ValueListenableBuilder( + valueListenable: controller.select((state) => state.idle), + builder: (context, isIdle, _) => ElevatedButton( + onPressed: isIdle ? () => controller.increment() : null, + child: Text('Increment'), + ), +) +``` + +## Advanced Features + +### Error Handling + +The `handle()` method provides built-in error handling: + +```dart +void riskyOperation() => handle( + () async { + // Your operation + throw Exception('Something went wrong'); + }, + error: (error, stackTrace) async { + // Handle error + print('Error: $error'); + }, + done: () async { + // Always called, even if error occurs + print('Operation completed'); + }, + name: 'riskyOperation', // For debugging +); +``` + +### Observer Pattern + +Monitor all controller events for debugging: + +```dart +class MyObserver implements IControllerObserver { + @override + void onCreate(Controller controller) { + print('Controller created: ${controller.name}'); + } + + @override + void onHandler(HandlerContext context) { + print('Handler started: ${context.name}'); + } + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) { + print('State changed: $prevState -> $nextState'); + } + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + print('Error in ${controller.name}: $error'); + } + + @override + void onDispose(Controller controller) { + print('Controller disposed: ${controller.name}'); + } +} + +void main() { + Controller.observer = MyObserver(); + runApp(MyApp()); +} +``` + +### Mutex + +Use `Mutex` for custom synchronization: + +```dart +final mutex = Mutex(); + +// Method 1: synchronize (automatic unlock) +await mutex.synchronize(() async { + // Critical section +}); + +// Method 2: lock/unlock (manual control) +final unlock = await mutex.lock(); +try { + // Critical section + if (someCondition) { + unlock(); + return; // Early exit + } + // More code +} finally { + unlock(); +} + +// Check if locked +if (mutex.locked) { + print('Mutex is currently locked'); +} +``` + +## Migration from 0.x to 1.0.0 + +See [MIGRATION.md](MIGRATION.md) for detailed migration guide. + +**Key changes:** +- Remove `base` from controller classes +- `ConcurrentControllerHandler` is deprecated (remove it) +- Controllers are concurrent by default +- Use `Mutex` for custom concurrency patterns + +## Best Practices + +1. **Choose the right concurrency strategy:** + - Default (concurrent) for independent operations + - Sequential for operations that must complete in order + - Droppable for operations that should cancel if busy + - Custom Mutex for complex scenarios + +2. **Use `handle()` for all async operations:** + - Automatic error catching + - Observer notifications + - Proper disposal handling + +3. **Keep state immutable:** + - Use records or immutable classes for state + - Always create new state instances + +4. **Dispose controllers:** + - Controllers are automatically disposed by `ControllerScope` + - Manual disposal only needed for manually created controllers + +## Advanced Usage + +### UI Feedback with Callbacks + +Use `error` and `done` callbacks to provide user feedback through SnackBars, dialogs, or notifications: + +```dart +class UserController extends StateController { + UserController(this.api) : super(initialState: UserState.initial()); + + final UserApi api; + + Future updateProfile( + User user, { + void Function(User user)? onSuccess, + void Function(Object error)? onError, + }) => handle( + () async { + final updatedUser = await api.updateUser(user); + setState(state.copyWith(user: updatedUser)); + onSuccess?.call(updatedUser); + return updatedUser; + }, + error: (error, stackTrace) async { + onError?.call(error); + }, + name: 'updateProfile', + meta: {'userId': user.id}, + ); +} + +// Usage in UI +ElevatedButton( + onPressed: () => controller.updateProfile( + updatedUser, + onSuccess: (user) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Profile updated: ${user.name}')), + ); + }, + onError: (error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $error'), + backgroundColor: Colors.red, + ), + ); + }, + ), + child: const Text('Update Profile'), +) +``` + +### Interactive Dialogs During Processing + +Add interactive dialogs in the middle of processing for user input: + +```dart +class AuthController extends StateController { + AuthController(this.api) : super(initialState: AuthState.initial()); + + final AuthApi api; + + Future login( + String email, + String password, { + required Future Function() requestSmsCode, + }) => handle( + () async { + // Step 1: Initial login + final session = await api.login(email, password); + + // Step 2: Check if 2FA is required + if (session.requires2FA) { + // Request SMS code from user via dialog + final smsCode = await requestSmsCode(); + + // Step 3: Verify SMS code + await api.verify2FA(session.id, smsCode); + } + + setState(state.copyWith(isAuthenticated: true)); + return true; + }, + error: (error, stackTrace) async { + setState(state.copyWith(error: error.toString())); + }, + name: 'login', + meta: {'email': email, 'requires2FA': true}, + ); +} + +// Usage in UI +ElevatedButton( + onPressed: () => controller.login( + email, + password, + requestSmsCode: () async { + // Show dialog and wait for user input + final code = await showDialog( + context: context, + builder: (context) => SmsCodeDialog(), + ); + return code ?? ''; + }, + ), + child: const Text('Login'), +) +``` + +### Debugging and Observability + +Use `name` and `meta` parameters for debugging, logging, and integration with error tracking services like Sentry or Crashlytics: + +```dart +class ControllerObserver implements IControllerObserver { + const ControllerObserver(); + + @override + void onHandler(HandlerContext context) { + // Log operation start with metadata + print('START | ${context.controller.name}.${context.name}'); + print('META | ${context.meta}'); + + final stopwatch = Stopwatch()..start(); + + context.done.whenComplete(() { + // Log operation completion with duration + stopwatch.stop(); + print('DONE | ${context.controller.name}.${context.name} | ' + 'duration: ${stopwatch.elapsed}'); + }); + } + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + final context = Controller.context; + + if (context != null) { + // Send breadcrumbs to Sentry/Crashlytics + Sentry.addBreadcrumb(Breadcrumb( + message: '${controller.name}.${context.name}', + data: context.meta, + level: SentryLevel.error, + )); + + // Report error with full context + Sentry.captureException( + error, + stackTrace: stackTrace, + hint: Hint.withMap({ + 'controller': controller.name, + 'operation': context.name, + 'metadata': context.meta, + }), + ); + } + } + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) { + final context = Controller.context; + + // Log state changes with operation context + if (context != null) { + print('STATE | ${controller.name}.${context.name} | ' + '$prevState -> $nextState'); + print('META | ${context.meta}'); + } + } + + @override + void onCreate(Controller controller) { + print('CREATE | ${controller.name}'); + } + + @override + void onDispose(Controller controller) { + print('DISPOSE | ${controller.name}'); + } +} + +// Setup in main +void main() { + Controller.observer = const ControllerObserver(); + runApp(const App()); +} +``` + +**Benefits of using `name` and `meta`:** +- **Debugging**: Easily track which operation is executing +- **Logging**: Add context to logs for better traceability +- **Profiling**: Measure operation duration and performance +- **Error tracking**: Send rich context to Sentry/Crashlytics +- **Analytics**: Track user actions with metadata +- **Breadcrumbs**: Build execution trail for debugging crashes + +## Examples + +See [example/](example/) directory for complete examples: +- Basic counter +- Advanced concurrency patterns +- Error handling +- Custom observers + ## Coverage [![](https://codecov.io/gh/PlugFox/control/branch/master/graphs/sunburst.svg)](https://codecov.io/gh/PlugFox/control/branch/master) diff --git a/benchmark/mutex/linked_mutex.dart b/benchmark/mutex/linked_mutex.dart new file mode 100644 index 0000000..b3cd84b --- /dev/null +++ b/benchmark/mutex/linked_mutex.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +/// {@template linked_mutex} +/// A mutex implementation using a linked list of tasks. +/// This allows for synchronizing access to a critical section of code, +/// ensuring that only one task can execute the critical section at a time. +/// {@endtemplate} +class LinkedMutex { + /// Creates a new instance of the mutex. + /// + /// {@macro linked_mutex} + LinkedMutex(); + + /// The head of the linked list of mutex tasks. + _MutexTask? _head; + + /// Check if the mutex is currently locked. + bool get locked => _head != null; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// Future(() async { + /// final unlock = await mutex.lock(); + /// try { + /// await criticalSection(i); + /// } finally { + /// unlock(); + /// } + /// }); + /// ``` + Future lock() async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + try { + prior.next = node; + await prior.future; + } on Object {/* Ignore errors */} + } + return node.complete; + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action) async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + prior.next = node; + await prior.future; + } + try { + final result = await action(); + node.complete(); + return result; + } on Object { + node.complete(); + rethrow; + } finally { + if (identical(_head, node)) _head = null; + } + } +} + +/// A task in the linked list of mutex tasks. +final class _MutexTask { + _MutexTask.sync() : _completer = Completer.sync(); + + final Completer _completer; + + /// The future that completes when the task is done. + Future get future => _completer.future; + + /// Executes the task. + /// After completion, it triggers the execution of the next task in the queue. + void complete() => _completer.complete(); + + /// Next task in the mutex queue. + _MutexTask? next; +} diff --git a/benchmark/mutex/queue_mutex.dart b/benchmark/mutex/queue_mutex.dart new file mode 100644 index 0000000..d28dc18 --- /dev/null +++ b/benchmark/mutex/queue_mutex.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:collection'; + +/// {@template queue_mutex} +/// A mutex implementation that uses a queue to manage access +/// to a critical section of code. +/// This ensures that only one task can execute the critical section at a time. +/// {@endtemplate} +class QueueMutex { + /// Creates a new instance of the mutex. + /// + /// {@macro queue_mutex} + QueueMutex(); + + /// Initial completed future used to represent an unlocked state. + static final Future _empty = Future.value(); + + /// Queue of completers representing tasks waiting for the mutex. + final DoubleLinkedQueue> _queue = + DoubleLinkedQueue>(); + + /// Check if the mutex is currently locked. + bool get locked => _queue.isNotEmpty; + + /// Returns the number of tasks waiting for the mutex. + int get tasks => _queue.length; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// The returned function can be called to unlock the mutex, + /// but it should only be called once and relatively expensive to call. + Future lock() { + final previous = _queue.lastOrNull?.future ?? _empty; + _queue.addLast(Completer.sync()); + return previous; + } + + /// Unlocks the mutex, allowing the next waiting task to proceed. + void unlock() { + if (_queue.isEmpty) { + assert(false, 'Mutex unlock called when no tasks are waiting.'); + return; + } + final completer = _queue.removeFirst(); // Remove the current lock holder + if (completer.isCompleted) { + assert(false, + 'Mutex unlock called when the completer is already completed.'); + return; + } + completer.complete(); + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action) async { + await lock(); + try { + return await action(); + } finally { + unlock(); + } + } +} diff --git a/benchmark/mutex/simple_mutex.dart b/benchmark/mutex/simple_mutex.dart new file mode 100644 index 0000000..ff6716f --- /dev/null +++ b/benchmark/mutex/simple_mutex.dart @@ -0,0 +1,36 @@ +/// {@template simple_mutex} +/// A simple mutex implementation that serializes access to a critical section +/// of code using a single future chain. +/// {@endtemplate} +class SimpleMutex { + /// Creates a new instance of the mutex. + /// + /// {@macro simple_mutex} + SimpleMutex(); + + /// The initial completed future used to represent an unlocked state. + static final Future _initial = Future.value(); + + Future _task = _initial; + + /// Indicates whether the mutex is currently locked. + bool get locked => !identical(_task, _initial); + + /// Executes a function while holding the mutex lock. + /// Beware that [action] should never throw exceptions. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action) async { + final prior = _task; + final task = _task = Future(() async { + if (!identical(_task, _initial)) await prior; + return action(); + }); + final result = await task; + if (identical(_task, task)) _task = _initial; + return result; + } +} diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart new file mode 100644 index 0000000..5e80b77 --- /dev/null +++ b/benchmark/mutex_benchmark.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:io' as io; + +import 'package:meta/meta.dart'; + +//import 'package:synchronized/synchronized.dart' as synchronized; + +import 'mutex/linked_mutex.dart'; +import 'mutex/queue_mutex.dart'; +import 'mutex/simple_mutex.dart'; + +void main() => runZonedGuarded( + () async { + io.stdout.writeln('Starting mutex benchmark...'); + final benchmarks = [ + WithoutMutexBenchmark(), + SimpleMutexBenchmark(), + //SynchronizedBenchmark(), + QueueMutexBenchmark(), + LinkedMutexBenchmark(), + LinkedLockBenchmark(), + ]; + + final ranks = + <({double score, int iterations, int elapsed, String name})>[]; + + for (final benchmark in benchmarks) { + final result = await benchmark.measure(); + final iterations = result.iterations; + final elapsed = result.elapsed; + final score = iterations / (elapsed / 1000); + ranks.add(( + score: score, + iterations: iterations, + elapsed: elapsed, + name: benchmark.name + )); + } + + final buffer = StringBuffer('Mutex Benchmark Results:\n'); + ranks.sort((a, b) => b.score.compareTo(a.score)); + for (final rank in ranks) { + buffer.writeln('${rank.name.padLeft(16, ' ')}: ' + '${rank.score.toStringAsFixed(2)} ops/sec ' + '(${rank.iterations} iterations in ${rank.elapsed} µs)'); + } + io.stdout.writeln(buffer.toString()); + await io.stdout.flush(); + io.exit(0); + }, + (error, stack) async { + io.stderr.writeln('Error in mutex benchmark: $error\n$stack'); + await io.stderr.flush(); + io.exit(1); + }, + ); + +abstract class BenchmarkBase { + BenchmarkBase() : _counter = 0; + + /// The name of the benchmark. + String get name; + + /// Internal counter for executed iterations. + int _counter; + + /// Measures the score for this benchmark by executing it repeatedly until + /// time minimum has been reached. + static Future<({int iterations, int elapsed})> _measureFor( + Future Function() f, int minimumMillis) async { + const batchSize = 100000; + final futures = List>.filled(batchSize, Future.value()); + final minimumMicros = minimumMillis * 1000; + var iter = 0; + var elapsed = 0; + try { + final watch = Stopwatch()..start(); + while (elapsed < minimumMicros) { + for (var i = 0; i < batchSize; i++) { + futures[i] = f(); + iter++; + } + await Future.wait(futures); + elapsed = watch.elapsedMicroseconds; + } + } on Object catch (e) { + io.stderr.writeln('Error during benchmark measurement: $e'); + rethrow; + } + return (iterations: iter, elapsed: elapsed); + } + + /// Resets the benchmark state. + void reset() { + _counter = 0; + } + + Future run(); + + @mustCallSuper + void test(int iterations) { + if (iterations <= 0) + throw StateError('Invalid iterations count in test: $iterations.'); + if (_counter != iterations) + throw StateError('Test for $name mismatch: ' + 'expected $iterations, got $_counter.'); + } + + /// Measures the score for the benchmark and returns it. + @mustCallSuper + Future<({int iterations, int elapsed})> measure() async { + // Warmup for at least 100ms. Discard result. + await _measureFor(run, 100); + // Reset state + reset(); + // Run the benchmark for at least 2000ms. + final result = await _measureFor(run, 2000); + // Test result + test(result.iterations); + return result; + } +} + +class WithoutMutexBenchmark extends BenchmarkBase { + WithoutMutexBenchmark(); + + @override + String get name => 'WithoutMutex'; + + @override + Future run() => Future.delayed(Duration.zero, () { + _counter++; + }); +} + +class QueueMutexBenchmark extends BenchmarkBase { + QueueMutexBenchmark(); + + @override + String get name => 'QueueMutex'; + + final QueueMutex _m = QueueMutex(); + + @override + Future run() => _m.synchronize(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); +} + +class SimpleMutexBenchmark extends BenchmarkBase { + SimpleMutexBenchmark(); + + @override + String get name => 'SimpleMutex'; + + final SimpleMutex _m = SimpleMutex(); + + @override + Future run() => _m.synchronize(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); + + @override + void test(int iterations) { + super.test(iterations); + if (_m.locked) throw StateError('SimpleMutex is still locked.'); + } +} + +/* class SynchronizedBenchmark extends BenchmarkBase { + SynchronizedBenchmark(); + + @override + String get name => 'Synchronized'; + + final synchronized.Lock _lock = synchronized.Lock(); + + @override + Future run() => _lock.synchronized(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); + + @override + void test(int iterations) { + super.test(iterations); + if (_lock.locked) throw StateError('Synchronized lock is still locked.'); + } +} */ + +class LinkedMutexBenchmark extends BenchmarkBase { + LinkedMutexBenchmark(); + + @override + String get name => 'LinkedMutex'; + + final LinkedMutex _m = LinkedMutex(); + + @override + Future run() => _m.synchronize(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); +} + +class LinkedLockBenchmark extends BenchmarkBase { + LinkedLockBenchmark(); + + @override + String get name => 'LinkedLock'; + + final LinkedMutex _m = LinkedMutex(); + + @override + Future run() async { + final unlock = await _m.lock(); + try { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + } finally { + unlock(); + } + } +} diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index dda2873..9b6c13b 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -52,7 +52,6 @@ analyzer: always_declare_return_types: info # Warning - unsafe_html: warning missing_return: warning missing_required_param: warning no_logic_in_create_state: warning @@ -153,7 +152,6 @@ linter: unnecessary_statements: true unnecessary_string_escapes: true unnecessary_string_interpolations: true - unsafe_html: true use_full_hex_values_for_flutter_colors: true use_raw_strings: true use_string_buffers: true @@ -208,7 +206,6 @@ linter: non_constant_identifier_names: true constant_identifier_names: true directives_ordering: true - package_api_docs: true implementation_imports: true prefer_interpolation_to_compose_strings: true unnecessary_brace_in_string_interps: true diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..1dc6cf7 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index d97f17e..e51a31d 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9242e33..0f755c4 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -20,10 +20,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + shared_preferences_foundation: 4e65c567e7877037d328829a522222c938bf308c -PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 +PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index e82cf5e..86ff267 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -453,7 +453,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -580,7 +580,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -629,7 +629,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5d..e3773d4 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/lib/main.dart b/example/lib/main.dart index 7bdefdf..cabf472 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -22,7 +22,8 @@ final class ControllerObserver implements IControllerObserver { void onHandler(HandlerContext context) { final stopwatch = Stopwatch()..start(); l.d( - 'Controller | ' '${context.controller.name}.${context.name}', + 'Controller | ' + '${context.controller.name}.${context.name}', context.meta, ); context.done.whenComplete(() { @@ -85,35 +86,69 @@ final class ControllerObserver implements IControllerObserver { } } -void main() => runZonedGuarded>( - () async { - // Setup controller observer - Controller.observer = const ControllerObserver(); - runApp(const App()); - }, - (error, stackTrace) => l.e('Top level exception: $error', stackTrace), - ); +void main() => runZonedGuarded>(() async { + // Setup controller observer + Controller.observer = const ControllerObserver(); + runApp(const App()); +}, (error, stackTrace) => l.e('Top level exception: $error', stackTrace)); /// Counter state for [CounterController] typedef CounterState = ({int count, bool idle}); -/// Counter controller -final class CounterController extends StateController +/// Counter controller with sequential handler +class CounterController extends StateController with SequentialControllerHandler { + /// Creates a [CounterController] with an optional initial state. CounterController({CounterState? initialState}) - : super(initialState: initialState ?? (idle: true, count: 0)); - - void add(int value) => handle(() async { - setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count + value)); - }); - - void subtract(int value) => handle(() async { - setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count - value)); - }); + : super(initialState: initialState ?? (idle: true, count: 0)); + + /// Adds a value to the current count. + Future add( + int value, { + void Function(int result)? onSuccess, + void Function(Object error, StackTrace stackTrace)? onError, + }) => handle( + () async { + setState((idle: false, count: state.count)); + final result = await Future.delayed( + const Duration(milliseconds: 1500), + () => state.count + value, + ); + setState((idle: true, count: result)); + onSuccess?.call(result); + return result; + }, + error: (error, stackTrace) async { + onError?.call(error, stackTrace); + }, + done: () async {}, + name: 'add', + meta: {'operation': 'add', 'value': value}, + ); + + /// Subtracts a value from the current count. + Future subtract( + int value, { + void Function(int result)? onSuccess, + void Function(Object error, StackTrace stackTrace)? onError, + }) => handle( + () async { + setState((idle: false, count: state.count)); + final result = await Future.delayed( + const Duration(milliseconds: 1500), + () => state.count - value, + ); + onSuccess?.call(result); + setState((idle: true, count: result)); + return result; + }, + error: (error, stackTrace) async { + onError?.call(error, stackTrace); + }, + done: () async {}, + name: 'subtract', + meta: {'operation': 'subtract', 'value': value}, + ); } class App extends StatelessWidget { @@ -121,15 +156,13 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - title: 'StateController example', - theme: ThemeData.dark(), - home: const CounterScreen(), - builder: (context, child) => - // Create and inject the controller into the element tree. - ControllerScope( - CounterController.new, - child: child, - )); + title: 'StateController example', + theme: ThemeData.dark(), + home: const CounterScreen(), + builder: (context, child) => + // Create and inject the controller into the element tree. + ControllerScope(CounterController.new, child: child), + ); } class CounterScreen extends StatelessWidget { @@ -137,22 +170,14 @@ class CounterScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Counter'), - ), - floatingActionButton: const CounterScreen$Buttons(), - body: const SafeArea( - child: Center( - child: CounterScreen$Text(), - ), - ), - ); + appBar: AppBar(title: const Text('Counter')), + floatingActionButton: const CounterScreen$Buttons(), + body: const SafeArea(child: Center(child: CounterScreen$Text())), + ); } class CounterScreen$Text extends StatelessWidget { - const CounterScreen$Text({ - super.key, - }); + const CounterScreen$Text({super.key}); @override Widget build(BuildContext context) { @@ -163,10 +188,7 @@ class CounterScreen$Text extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'Count: ', - style: style, - ), + Text('Count: ', style: style), SizedBox.square( dimension: 64, child: Center( @@ -182,10 +204,7 @@ class CounterScreen$Text extends StatelessWidget { duration: const Duration(milliseconds: 500), transitionBuilder: (child, animation) => ScaleTransition( scale: animation, - child: FadeTransition( - opacity: animation, - child: child, - ), + child: FadeTransition(opacity: animation, child: child), ), child: state.idle ? Text(text, style: style, overflow: TextOverflow.fade) @@ -201,43 +220,58 @@ class CounterScreen$Text extends StatelessWidget { } class CounterScreen$Buttons extends StatelessWidget { - const CounterScreen$Buttons({ - super.key, - }); + const CounterScreen$Buttons({super.key}); + + /// Show a message using a [SnackBar]. + static void showMessage(BuildContext context, String message) { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?..clearSnackBars() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); + } @override Widget build(BuildContext context) => ValueListenableBuilder( - // Transform [StateController] in to [ValueListenable] - valueListenable: context - .controllerOf() - .select((state) => state.idle), - builder: (context, idle, _) => IgnorePointer( - ignoring: !idle, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 350), - opacity: idle ? 1 : .25, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FloatingActionButton( - key: ValueKey('add#${idle ? 'enabled' : 'disabled'}'), - onPressed: idle - ? () => context.controllerOf().add(1) - : null, - child: const Icon(Icons.add), - ), - const SizedBox(height: 8), - FloatingActionButton( - key: ValueKey('subtract#${idle ? 'enabled' : 'disabled'}'), - onPressed: idle - ? () => - context.controllerOf().subtract(1) - : null, - child: const Icon(Icons.remove), - ), - ], + // Transform [StateController] in to [ValueListenable] + valueListenable: context.controllerOf().select( + (state) => state.idle, + ), + builder: (context, idle, _) => IgnorePointer( + ignoring: !idle, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 350), + opacity: idle ? 1 : .25, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + key: ValueKey('add#${idle ? 'enabled' : 'disabled'}'), + onPressed: idle + ? () => context.controllerOf().add( + 1, + onSuccess: (result) => + showMessage(context, 'Result: $result'), + ) + : null, + child: const Icon(Icons.add), ), - ), + const SizedBox(height: 8), + FloatingActionButton( + key: ValueKey('subtract#${idle ? 'enabled' : 'disabled'}'), + onPressed: idle + ? () => context.controllerOf().subtract( + 1, + onSuccess: (result) => + showMessage(context, 'Result: $result'), + ) + : null, + child: const Icon(Icons.remove), + ), + ], ), - ); + ), + ), + ); } diff --git a/example/macos/Podfile b/example/macos/Podfile index c795730..b52666a 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 687eba4..205e9b0 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -15,9 +15,9 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin SPEC CHECKSUMS: - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 4e65c567e7877037d328829a522222c938bf308c -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 1587ee1..c790b60 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -553,7 +553,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -632,7 +632,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -679,7 +679,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15368ec..ac78810 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/example/pubspec.lock b/example/pubspec.lock index 095c9a0..5b90c59 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "10.0.1" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.7.0" async: dependency: "direct main" description: @@ -45,18 +45,18 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.4" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" build_daemon: dependency: transitive description: @@ -65,30 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" + sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc url: "https://pub.dev" source: hosted - version: "2.4.7" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 - url: "https://pub.dev" - source: hosted - version: "7.2.11" + version: "2.11.0" built_collection: dependency: transitive description: @@ -101,18 +85,18 @@ packages: dependency: transitive description: name: built_value - sha256: c9aabae0718ec394e5bc3c7272e6bb0dc0b32201a08fe185ec1d8401d3e39309 + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.8.1" + version: "8.12.3" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -125,10 +109,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -141,17 +125,17 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" control: dependency: "direct main" description: path: ".." relative: true source: path - version: "0.2.0" + version: "1.0.0-dev.1" convert: dependency: "direct main" description: @@ -180,18 +164,18 @@ packages: dependency: transitive description: name: dart_style - sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" + sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "3.1.5" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: @@ -230,10 +214,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "6.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -244,14 +228,6 @@ packages: description: flutter source: sdk version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" - url: "https://pub.dev" - source: hosted - version: "3.2.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -302,14 +278,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" json_annotation: dependency: transitive description: @@ -322,42 +290,42 @@ packages: dependency: "direct main" description: name: l - sha256: c015d97a7d1552706be9b2367facd2c379ffdabf1e104bc19d284ae775fc9016 + sha256: "48db3024c2f74e1bc84b753b17d6754a066969c246de59505d6306f5154cbc86" url: "https://pub.dev" source: hosted - version: "5.0.0-pre.2" + version: "5.0.1" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "6.1.0" logging: dependency: transitive description: @@ -370,10 +338,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -386,10 +354,10 @@ packages: dependency: "direct main" description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.17.0" mime: dependency: transitive description: @@ -410,10 +378,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider_linux: dependency: transitive description: @@ -562,7 +530,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -575,18 +543,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -623,18 +591,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.2" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" + version: "0.7.7" typed_data: dependency: transitive description: @@ -647,10 +607,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -716,5 +676,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b0f003d..0100901 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: example description: "Control example" -publish_to: 'none' +publish_to: "none" homepage: https://github.com/PlugFox/control @@ -32,8 +32,8 @@ platforms: version: 1.0.0+1 environment: - sdk: '>=3.4.0 <4.0.0' - flutter: ">=3.16.0" + sdk: ">=3.9.0 <4.0.0" + flutter: ">=3.38.0" dependencies: # Flutter SDK @@ -48,7 +48,7 @@ dependencies: convert: any # Logger - l: ^5.0.0-pre.2 + l: ^5.0.1 # Storage shared_preferences: ^2.2.2 @@ -69,11 +69,11 @@ dev_dependencies: sdk: flutter # Linting - flutter_lints: ^2.0.1 + flutter_lints: ^6.0.0 # Code generation - build_runner: ^2.4.6 + build_runner: ^2.10.0 flutter: generate: true - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 85de676..99180b2 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -9,9 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('placeholder', (tester) async { - expect( - true, - isTrue, - ); + expect(true, isTrue); }); } diff --git a/lib/control.dart b/lib/control.dart index c07da81..fa857c8 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -4,5 +4,6 @@ export 'package:control/src/concurrency/concurrency.dart'; export 'package:control/src/controller.dart' hide IController; export 'package:control/src/controller_scope.dart' hide ControllerScope$Element; export 'package:control/src/handler_context.dart' show HandlerContext; +export 'package:control/src/mutex.dart'; export 'package:control/src/state_consumer.dart'; export 'package:control/src/state_controller.dart' hide IStateController; diff --git a/lib/src/concurrency/concurrent_controller_handler.dart b/lib/src/concurrency/concurrent_controller_handler.dart index b2597aa..1380d26 100644 --- a/lib/src/concurrency/concurrent_controller_handler.dart +++ b/lib/src/concurrency/concurrent_controller_handler.dart @@ -1,142 +1,31 @@ -import 'dart:async'; - import 'package:control/src/controller.dart'; -import 'package:control/src/handler_context.dart'; -import 'package:meta/meta.dart'; /// A mixin that provides concurrent controller concurrency handling. -/// This mixin should be used on classes that extend [Controller]. -base mixin ConcurrentControllerHandler on Controller { - @override - @nonVirtual - bool get isProcessing => _$processingCalls > 0; - - /// Tracks the number of ongoing processing calls. - int _$processingCalls = 0; - - /// Handles a given operation with error handling and completion tracking. - /// - /// [handler] is the main operation to be executed. - /// [error] is an optional error handler. - /// [done] is an optional callback to be executed when the operation is done. - /// [name] is an optional name for the operation, used for debugging. - /// [meta] is an optional HashMap of context data to be passed to the zone. - @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - String? name, - Map? meta, - }) { - if (isDisposed) return Future.value(null); - _$processingCalls++; - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - if (isDisposed) return; - try { - super.onError(e, st); - if (isDone || isDisposed || completer.isCompleted) return; - await error?.call(e, st); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - } - - Future handleZoneError(Object error, StackTrace stackTrace) async { - if (isDisposed) return; - super.onError(error, stackTrace); - assert( - false, - 'A zone error occurred during controller event handling. ' - 'This may be caused by an unawaited future. ' - 'Make sure to await all futures in the controller ' - 'event handlers.', - ); - } - - void onDone() { - if (completer.isCompleted) return; - _$processingCalls--; - completer.complete(); - } - - final handlerContext = HandlerContextImpl( - controller: this, - name: name ?? 'handler#${handler.runtimeType}', - completer: completer, - meta: { - ...?meta, - }, - ); - - runZonedGuarded( - () async { - try { - if (isDisposed) return; - Controller.observer?.onHandler(handlerContext); - await handler(); - } on Object catch (error, stackTrace) { - await onError(error, stackTrace); - } finally { - isDone = true; - try { - await done?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } finally { - onDone(); - } - } - }, - handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, - ); - - return completer.future; - } - - /* @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - }) => - runZonedGuarded( - () async { - if (isDisposed) return; - _$processingCalls++; - _done ??= Completer.sync(); - try { - await handler(); - } on Object catch (e, st) { - onError(e, st); - await Future(() async { - await error?.call(e, st); - }).catchError(onError); - } finally { - isDone = true; - await Future(() async { - await done?.call(); - }).catchError(onError); - _$processingCalls--; - if (_$processingCalls == 0) { - final completer = _done; - if (completer != null && !completer.isCompleted) { - completer.complete(); - } - _done = null; - } - } - }, - onError, - ); */ +/// +/// **Deprecated:** This mixin is no longer needed as [Controller] handles +/// operations concurrently by default. Simply remove this mixin from your +/// controller declarations. +/// +/// Before: +/// ```dart +/// class MyController extends StateController +/// with ConcurrentControllerHandler { +/// // ... +/// } +/// ``` +/// +/// After: +/// ```dart +/// class MyController extends StateController { +/// // Operations execute concurrently by default +/// } +/// ``` +@Deprecated( + 'ConcurrentControllerHandler is no longer needed. ' + 'Controller handles operations concurrently by default. ' + 'Remove this mixin from your controller declarations.', +) +mixin ConcurrentControllerHandler on Controller { + // Empty mixin - base Controller behavior is already concurrent + // This is kept for backwards compatibility only } diff --git a/lib/src/concurrency/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart index 4f034b1..af173dd 100644 --- a/lib/src/concurrency/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -1,101 +1,55 @@ import 'dart:async'; import 'package:control/src/controller.dart'; -import 'package:control/src/handler_context.dart'; +import 'package:control/src/mutex.dart'; import 'package:meta/meta.dart'; -/// Droppable controller concurrency -base mixin DroppableControllerHandler on Controller { +/// Droppable controller concurrency handler. +/// +/// This mixin drops new operations if one is already running. +/// When an operation is in progress, new calls to [handle] return null +/// without executing the handler. +/// +/// Example: +/// ```dart +/// class MyController extends StateController +/// with DroppableControllerHandler { +/// void operation1() => handle(() async { ... }); +/// void operation2() => handle(() async { ... }); +/// // If operation1 is running, operation2 is dropped +/// } +/// ``` +mixin DroppableControllerHandler on Controller { + final Mutex _$mutex = Mutex(); + @override - @nonVirtual - bool get isProcessing => _$processingCalls > 0; - int _$processingCalls = 0; + bool get isProcessing => _$mutex.locked; - /// Handles a given operation with error handling and completion tracking. + /// Handles a given operation with droppable behavior. /// - /// [handler] is the main operation to be executed. - /// [error] is an optional error handler. - /// [done] is an optional callback to be executed when the operation is done. - /// [name] is an optional name for the operation, used for debugging. - /// [meta] is an optional HashMap of context data to be passed to the zone. + /// If an operation is already running, the new one is dropped and null + /// is returned. @override @protected @mustCallSuper - Future handle( - Future Function() handler, { + Future handle( + Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, Map? meta, }) { - if (isDisposed || isProcessing) return Future.value(null); - _$processingCalls++; - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - if (isDisposed) return; - try { - super.onError(e, st); - if (isDone || isDisposed || completer.isCompleted) return; - await error?.call(e, st); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - } - - Future handleZoneError(Object error, StackTrace stackTrace) async { - if (isDisposed) return; - super.onError(error, stackTrace); - assert( - false, - 'A zone error occurred during controller event handling. ' - 'This may be caused by an unawaited future. ' - 'Make sure to await all futures in the controller ' - 'event handlers.', - ); - } - - void onDone() { - if (completer.isCompleted) return; - _$processingCalls--; - completer.complete(); - } - - final handlerContext = HandlerContextImpl( - controller: this, - name: name ?? 'handler#${handler.runtimeType}', - completer: completer, - meta: { - ...?meta, - }, - ); - - runZonedGuarded( - () async { - try { - if (isDisposed) return; - Controller.observer?.onHandler(handlerContext); - await handler(); - } on Object catch (error, stackTrace) { - await onError(error, stackTrace); - } finally { - isDone = true; - try { - await done?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } finally { - onDone(); - } - } - }, - handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, + // If already locked, drop this operation and return null + if (_$mutex.locked) return Future.value(null); + + return _$mutex.synchronize( + () => super.handle( + handler, + error: error, + done: done, + name: name, + meta: meta, + ), ); - - return completer.future; } } diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index 41f505a..bdb0e66 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -1,181 +1,48 @@ import 'dart:async'; -import 'dart:collection'; import 'package:control/src/controller.dart'; -import 'package:control/src/handler_context.dart'; +import 'package:control/src/mutex.dart'; import 'package:meta/meta.dart'; -/// Sequential controller concurrency -base mixin SequentialControllerHandler on Controller { - final _ControllerEventQueue _eventQueue = _ControllerEventQueue(); +/// Sequential controller concurrency handler. +/// +/// This mixin ensures that all operations execute sequentially (one at a time) +/// by wrapping the base [Controller.handle] method with a [Mutex]. +/// +/// Example: +/// ```dart +/// class MyController extends StateController +/// with SequentialControllerHandler { +/// void operation1() => handle(() async { ... }); +/// void operation2() => handle(() async { ... }); +/// // Operations execute one after another, never in parallel +/// } +/// ``` +mixin SequentialControllerHandler on Controller { + final Mutex _$mutex = Mutex(); @override - @nonVirtual - bool get isProcessing => _eventQueue.length > 0; + bool get isProcessing => _$mutex.locked; - /// Handles a given operation with error handling and completion tracking. + /// Handles a given operation sequentially. /// - /// [handler] is the main operation to be executed. - /// [error] is an optional error handler. - /// [done] is an optional callback to be executed when the operation is done. - /// [name] is an optional name for the operation, used for debugging. - /// [meta] is an optional HashMap of context data to be passed to the zone. + /// Operations are queued and executed one at a time. @override @protected @mustCallSuper - Future handle( - Future Function() handler, { + Future handle( + Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, Map? meta, - }) => - _eventQueue.push( - () { - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - if (isDisposed) return; - try { - super.onError(e, st); - if (isDone || isDisposed || completer.isCompleted) return; - await error?.call(e, st); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - } - - Future handleZoneError( - Object error, StackTrace stackTrace) async { - if (isDisposed) return; - super.onError(error, stackTrace); - assert( - false, - 'A zone error occurred during controller event handling. ' - 'This may be caused by an unawaited future. ' - 'Make sure to await all futures in the controller ' - 'event handlers.', - ); - } - - final handlerContext = HandlerContextImpl( - controller: this, - name: name ?? 'handler#${handler.runtimeType}', - completer: completer, - meta: { - ...?meta, - }, - ); - - void onDone() { - if (completer.isCompleted) return; - completer.complete(); - } - - runZonedGuarded( - () async { - try { - if (isDisposed) return; - Controller.observer?.onHandler(handlerContext); - await handler(); - } on Object catch (error, stackTrace) { - await onError(error, stackTrace); - } finally { - isDone = true; - try { - await done?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } finally { - onDone(); - } - } - }, - handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, - ); - - return completer.future; - }, - ).catchError((_, __) => null); -} - -final class _ControllerEventQueue { - _ControllerEventQueue(); - - final DoubleLinkedQueue<_SequentialTask> _queue = - DoubleLinkedQueue<_SequentialTask>(); - Future? _processing; - bool _isClosed = false; - - /// Event queue length. - int get length => _queue.length; - - /// Push it at the end of the queue. - Future push(Future Function() fn) { - final task = _SequentialTask(fn); - _queue.add(task); - _exec(); - return task.future; - } - - /// Mark the queue as closed. - /// The queue will be processed until it's empty. - /// But all new and current events will be rejected with [WSClientClosed]. - Future close() async { - _isClosed = true; - await _processing; - } - - /// Execute the queue. - void _exec() => _processing ??= Future.doWhile(() async { - final event = _queue.first; - try { - if (_isClosed) { - event.reject(StateError('Controller\'s event queue are disposed'), - StackTrace.current); - } else { - await event(); - } - } on Object catch (error, stackTrace) { - /* warning( - error, - stackTrace, - 'Error while processing event "${event.id}"', - ); */ - Future.sync(() => event.reject(error, stackTrace)).ignore(); - } - _queue.removeFirst(); - final isEmpty = _queue.isEmpty; - if (isEmpty) _processing = null; - return !isEmpty; - }); -} - -class _SequentialTask { - _SequentialTask(Future Function() fn) - : _fn = fn, - _completer = Completer(); - - final Completer _completer; - - final Future Function() _fn; - - Future get future => _completer.future; - - Future call() async { - final result = await _fn(); - if (!_completer.isCompleted) { - _completer.complete(result); - } - return result; - } - - void reject(Object error, [StackTrace? stackTrace]) { - if (_completer.isCompleted) return; - _completer.completeError(error, stackTrace); - } + }) => _$mutex.synchronize( + () => super.handle( + handler, + error: error, + done: done, + name: name, + meta: meta, + ), + ); } diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 340ed7a..308e68e 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:control/src/handler_context.dart'; -import 'package:control/src/registry.dart'; import 'package:control/src/state_controller.dart'; import 'package:flutter/foundation.dart' show ChangeNotifier, Listenable, VoidCallback; @@ -40,6 +39,12 @@ abstract interface class IController implements Listenable { /// Depending on the implementation, the handler may be executed /// sequentially, concurrently, dropped and etc. /// + /// Returns [Future] where null indicates the operation was + /// cancelled or dropped (e.g., controller disposed or busy). + /// + /// The [handler] can return a value of type [T]. + /// The [error] callback is called when an error occurs. + /// The [done] callback is called when the operation completes. /// The [name] parameter is used to identify the handler. /// The [meta] parameter is used to pass additional /// information to the handler's zone. @@ -48,8 +53,10 @@ abstract interface class IController implements Listenable { /// - [ConcurrentControllerHandler] - handler that executes concurrently /// - [SequentialControllerHandler] - handler that executes sequentially /// - [DroppableControllerHandler] - handler that drops the request when busy - void handle( - Future Function() handler, { + Future handle( + Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? error, + Future Function()? done, String? name, Map? meta, }); @@ -68,7 +75,10 @@ abstract interface class IControllerObserver { /// Called on any state change in the [StateController]. void onStateChanged( - StateController controller, S prevState, S nextState); + StateController controller, + S prevState, + S nextState, + ); /// Called on any error in the controller. void onError(Controller controller, Object error, StackTrace stackTrace); @@ -78,13 +88,14 @@ abstract interface class IControllerObserver { /// The controller responsible for processing the logic, /// the connection of widgets and the date of the layer. /// {@endtemplate} -abstract base class Controller with ChangeNotifier implements IController { +abstract class Controller with ChangeNotifier implements IController { /// {@macro controller} Controller() { - ControllerRegistry().insert(this); runZonedGuarded( () => Controller.observer?.onCreate(this), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line ); } @@ -112,25 +123,113 @@ abstract base class Controller with ChangeNotifier implements IController { int get subscribers => _$subscribers; int _$subscribers = 0; + @override + bool get isProcessing => _$processingCalls > 0; + int _$processingCalls = 0; + /// Error handling callback @protected void onError(Object error, StackTrace stackTrace) => runZonedGuarded( - () => Controller.observer?.onError(this, error, stackTrace), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line - ); + () => Controller.observer?.onError(this, error, stackTrace), + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line + ); /// Handles a given operation with error handling and completion tracking. /// + /// By default, operations execute concurrently. To change this behavior, + /// use concurrency handler mixins like [SequentialControllerHandler] + /// or [DroppableControllerHandler], or use [Mutex] for custom control. + /// + /// This method provides: + /// - Zone for error catching (including unawaited futures) + /// - HandlerContext for debugging + /// - Observer notifications + /// - error/done callbacks + /// /// [handler] is the main operation to be executed. + /// [error] is an optional error handler. + /// [done] is an optional callback to be executed when the operation is done. /// [name] is an optional name for the operation, used for debugging. /// [meta] is an optional HashMap of context data to be passed to the zone. @protected + @mustCallSuper @override - Future handle( - Future Function() handler, { + Future handle( + Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? error, + Future Function()? done, String? name, Map? meta, - }); + }) { + if (isDisposed) return Future.value(null); + _$processingCalls++; + final completer = Completer(); + var isDone = false; // ignore error callback after done + + Future onError(Object e, StackTrace st) async { + if (isDisposed) return; + try { + this.onError(e, st); + if (isDone || isDisposed || completer.isCompleted) return; + await error?.call(e, st); + } on Object catch (error, stackTrace) { + this.onError(error, stackTrace); + } + } + + Future handleZoneError(Object error, StackTrace stackTrace) async { + if (isDisposed) return; + this.onError(error, stackTrace); + assert( + false, + 'A zone error occurred during controller event handling. ' + 'This may be caused by an unawaited future. ' + 'Make sure to await all futures in the controller ' + 'event handlers.', + ); + } + + void onDone(T? result) { + if (completer.isCompleted) return; + _$processingCalls--; + completer.complete(result); + } + + final handlerContext = HandlerContextImpl( + controller: this, + name: name ?? 'handler#${handler.runtimeType}', + completer: completer, + meta: {...?meta}, + ); + + runZonedGuarded( + () async { + T? result; + try { + if (isDisposed) return; + Controller.observer?.onHandler(handlerContext); + result = await handler(); + } on Object catch (error, stackTrace) { + await onError(error, stackTrace); + } finally { + isDone = true; + try { + await done?.call(); + } on Object catch (error, stackTrace) { + this.onError(error, stackTrace); + } finally { + onDone(result); + } + } + }, + handleZoneError, + zoneValues: {HandlerContext.key: handlerContext}, + ); + + return completer.future; + } @protected @nonVirtual @@ -173,9 +272,10 @@ abstract base class Controller with ChangeNotifier implements IController { _$subscribers = 0; runZonedGuarded( () => Controller.observer?.onDispose(this), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line ); - ControllerRegistry().remove(); super.dispose(); } } diff --git a/lib/src/controller_scope.dart b/lib/src/controller_scope.dart index 0d3d190..e52231b 100644 --- a/lib/src/controller_scope.dart +++ b/lib/src/controller_scope.dart @@ -25,19 +25,16 @@ class ControllerScope extends InheritedWidget { Widget? child, bool lazy = true, super.key, - }) : _dependency = _ControllerDependency$Create( - create: create, - lazy: lazy, - ), - super(child: child ?? const SizedBox.shrink()); + }) : _dependency = _ControllerDependency$Create( + create: create, + lazy: lazy, + ), + super(child: child ?? const SizedBox.shrink()); /// {@macro controller_scope} - ControllerScope.value( - C controller, { - Widget? child, - super.key, - }) : _dependency = _ControllerDependency$Value(controller: controller), - super(child: child ?? const SizedBox.shrink()); + ControllerScope.value(C controller, {Widget? child, super.key}) + : _dependency = _ControllerDependency$Value(controller: controller), + super(child: child ?? const SizedBox.shrink()); final _ControllerDependency _dependency; @@ -48,17 +45,17 @@ class ControllerScope extends InheritedWidget { BuildContext context, { bool listen = false, }) { - final element = - context.getElementForInheritedWidgetOfExactType>(); + final element = context + .getElementForInheritedWidgetOfExactType>(); if (listen && element != null) context.dependOnInheritedElement(element); return element is ControllerScope$Element ? element.controller : null; } static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( - 'Out of scope, not found inherited widget ' - 'a ControllerScope of the exact type', - 'out_of_scope', - ); + 'Out of scope, not found inherited widget ' + 'a ControllerScope of the exact type', + 'out_of_scope', + ); /// The state from the closest instance of this class /// that encloses the given context. @@ -89,10 +86,9 @@ final class ControllerScope$Element @override void debugFillProperties(DiagnosticPropertiesBuilder properties) => - super.debugFillProperties(_debugFillPropertiesBuilder( - _controller, - properties, - )); + super.debugFillProperties( + _debugFillPropertiesBuilder(_controller, properties), + ); @nonVirtual C? _controller; @@ -210,7 +206,8 @@ final class ControllerScope$Element _subscribed = false; // Dispose controller if it was created by this scope if (_dependency is _ControllerDependency$Create && - listenable is ChangeNotifier) listenable.dispose(); + listenable is ChangeNotifier) + listenable.dispose(); super.unmount(); } @@ -270,46 +267,55 @@ DiagnosticPropertiesBuilder _debugFillPropertiesBuilder( switch (controller) { case StateController sc: properties - ..add(DiagnosticsProperty>( - 'StateController', - sc, - )) + ..add( + DiagnosticsProperty>('StateController', sc), + ) ..add(StringProperty('State', sc.state.toString())) ..add(IntProperty('Subscribers', sc.subscribers)) - ..add(FlagProperty( - 'isDisposed', - value: sc.isDisposed, - ifTrue: 'Disposed', - ifFalse: 'Not disposed', - )) - ..add(FlagProperty( - 'isProcessing', - value: sc.isProcessing, - ifTrue: 'Processing', - ifFalse: 'Idle', - )); + ..add( + FlagProperty( + 'isDisposed', + value: sc.isDisposed, + ifTrue: 'Disposed', + ifFalse: 'Not disposed', + ), + ) + ..add( + FlagProperty( + 'isProcessing', + value: sc.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle', + ), + ); case Controller c: properties ..add(DiagnosticsProperty.lazy('Controller', () => c)) ..add(IntProperty('Subscribers', c.subscribers)) - ..add(FlagProperty( - 'isDisposed', - value: c.isDisposed, - ifTrue: 'Disposed', - ifFalse: 'Not disposed', - )) - ..add(FlagProperty( - 'isProcessing', - value: c.isProcessing, - ifTrue: 'Processing', - ifFalse: 'Idle', - )); + ..add( + FlagProperty( + 'isDisposed', + value: c.isDisposed, + ifTrue: 'Disposed', + ifFalse: 'Not disposed', + ), + ) + ..add( + FlagProperty( + 'isProcessing', + value: c.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle', + ), + ); case ValueListenable vl: properties - ..add(DiagnosticsProperty>.lazy( - 'ValueListenable', - () => vl, - )) + ..add( + DiagnosticsProperty>.lazy( + 'ValueListenable', + () => vl, + ), + ) ..add(StringProperty('Value', vl.value?.toString() ?? 'null')); case ChangeNotifier cn: properties.add(DiagnosticsProperty('ChangeNotifier', cn)); diff --git a/lib/src/handler_context.dart b/lib/src/handler_context.dart index d53ad99..a6231dc 100644 --- a/lib/src/handler_context.dart +++ b/lib/src/handler_context.dart @@ -10,9 +10,9 @@ abstract interface class HandlerContext { /// Get the handler's context from the current zone. static HandlerContext? zoned() => switch (Zone.current[HandlerContext.key]) { - HandlerContext context => context, - _ => null, - }; + HandlerContext context => context, + _ => null, + }; /// Controller that the handler is attached to. abstract final Controller controller; @@ -32,12 +32,12 @@ abstract interface class HandlerContext { @internal final class HandlerContextImpl implements HandlerContext { - HandlerContextImpl( - {required this.controller, - required this.name, - required this.meta, - required Completer completer}) - : _completer = completer; + HandlerContextImpl({ + required this.controller, + required this.name, + required this.meta, + required Completer completer, + }) : _completer = completer; @override final Controller controller; diff --git a/lib/src/mutex.dart b/lib/src/mutex.dart new file mode 100644 index 0000000..3e463be --- /dev/null +++ b/lib/src/mutex.dart @@ -0,0 +1,40 @@ +import 'package:control/src/util/linked_mutex.dart'; + +/// {@template mutex} +/// A mutex (mutual exclusion) is a synchronization primitive +/// that is used to protect shared resources from concurrent access. +/// {@endtemplate} +abstract class Mutex { + /// Creates a new instance of the mutex. + /// + /// {@macro mutex} + factory Mutex() => LinkedMutex(); + + /// Check if the mutex is currently locked. + bool get locked; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// Future(() async { + /// final unlock = await mutex.lock(); + /// try { + /// await criticalSection(i); + /// } finally { + /// unlock(); + /// } + /// }); + /// ``` + Future lock(); + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action); +} diff --git a/lib/src/registry.dart b/lib/src/registry.dart deleted file mode 100644 index 29e0657..0000000 --- a/lib/src/registry.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:control/src/controller.dart'; -import 'package:control/src/state_controller.dart'; -import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; - -/// StateRegistry Singleton class -/// Used to register and retrieve the state controllers at debug mode -/// to track the state of the controllers and leaks in the application. -@internal -final class ControllerRegistry with ControllerRegistry$Global { - factory ControllerRegistry() => _internalSingleton; - ControllerRegistry._internal(); - static final ControllerRegistry _internalSingleton = - ControllerRegistry._internal(); -} - -@internal -base mixin ControllerRegistry$Global { - late final Map>> _globalRegistry = - >>{}; - - @internal - List getAll() { - if (!kDebugMode) return const []; - final result = []; - for (final list in _globalRegistry.values) { - var j = 0; - for (var i = 0; i < list.length; i++) { - final wr = list[i]; - final target = wr.target; - if (target == null || target.isDisposed) continue; - if (i != j) list[j] = wr; - if (target is Controller) result.add(target); - j++; - } - list.length = j; - } - return result; - } - - /// Get the controller from the registry. - @internal - List get() { - if (!kDebugMode) return []; - final result = []; - final list = _globalRegistry[C]; - if (list == null) return result; - var j = 0; - for (var i = 0; i < list.length; i++) { - final wr = list[i]; - final target = wr.target; - if (target == null || target.isDisposed) continue; - if (i != j) list[j] = wr; - if (target is C) result.add(target); - j++; - } - list.length = j; - return result; - } - - /// Upsert the controller in the registry. - @internal - void insert(Controller controller) { - if (!kDebugMode) return; - remove(); - (_globalRegistry[Controller] ??= >[]) - .add(WeakReference(controller)); - } - - /// Remove the controller from the registry. - @internal - void remove() { - if (!kDebugMode) return; - final list = _globalRegistry[Controller]; - if (list == null) return; - var j = 0; - for (var i = 0; i < list.length; i++) { - if (list[i] is WeakReference) continue; - if (i != j) list[j] = list[i]; - j++; - } - list.length = j; - } -} diff --git a/lib/src/state_consumer.dart b/lib/src/state_consumer.dart index 577e5e2..f1dc071 100644 --- a/lib/src/state_consumer.dart +++ b/lib/src/state_consumer.dart @@ -5,16 +5,16 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; /// Fire when the state changes. -typedef StateConsumerListener, S extends Object> - = void Function(BuildContext context, C controller, S previous, S current); +typedef StateConsumerListener, S extends Object> = + void Function(BuildContext context, C controller, S previous, S current); /// Build when the method returns true. -typedef StateConsumerCondition = bool Function( - S previous, S current); +typedef StateConsumerCondition = + bool Function(S previous, S current); /// Rebuild the widget when the state changes. -typedef StateConsumerBuilder = Widget Function( - BuildContext context, S state, Widget? child); +typedef StateConsumerBuilder = + Widget Function(BuildContext context, S state, Widget? child); /// {@template state_consumer} /// StateConsumer widget. @@ -80,7 +80,8 @@ class _StateConsumerState, S extends Object> final oldController = oldWidget.controller, newController = widget.controller; if (identical(oldController, newController) || - oldController == newController) return; + oldController == newController) + return; _unsubscribe(); _controller = newController ?? ControllerScope.of(context, listen: false); @@ -125,14 +126,21 @@ class _StateConsumerState, S extends Object> @override void debugFillProperties(DiagnosticPropertiesBuilder properties) => - super.debugFillProperties(properties - ..add( - DiagnosticsProperty>('Controller', _controller)) - ..add(DiagnosticsProperty('State', _controller.state)) - ..add(FlagProperty('isProcessing', - value: _controller.isProcessing, - ifTrue: 'Processing', - ifFalse: 'Idle'))); + super.debugFillProperties( + properties + ..add( + DiagnosticsProperty>('Controller', _controller), + ) + ..add(DiagnosticsProperty('State', _controller.state)) + ..add( + FlagProperty( + 'isProcessing', + value: _controller.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle', + ), + ), + ); @override Widget build(BuildContext context) => diff --git a/lib/src/state_controller.dart b/lib/src/state_controller.dart index 0dfb6e6..3687612 100644 --- a/lib/src/state_controller.dart +++ b/lib/src/state_controller.dart @@ -3,11 +3,10 @@ import 'dart:async'; import 'package:control/src/controller.dart'; import 'package:control/src/handler_context.dart'; import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; /// Selector from [StateController] -typedef StateControllerSelector = Value Function( - S state); +typedef StateControllerSelector = + Value Function(S state); /// Filter for [StateController] typedef StateControllerFilter = bool Function(Value prev, Value next); @@ -24,7 +23,7 @@ abstract interface class IStateController } /// State controller -abstract base class StateController extends Controller +abstract class StateController extends Controller implements IStateController { /// State controller StateController({required S initialState}) : _$state = initialState; @@ -43,7 +42,9 @@ abstract base class StateController extends Controller void setState(S state) { runZonedGuarded( () => Controller.observer?.onStateChanged(this, _$state, state), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line ); _$state = state; if (isDisposed) return; @@ -81,8 +82,7 @@ abstract base class StateController extends Controller ValueListenable select( StateControllerSelector selector, [ StateControllerFilter? test, - ]) => - _StateController$ValueListenableSelect(this, selector, test); + ]) => _StateController$ValueListenableSelect(this, selector, test); @override void dispose() { diff --git a/lib/src/util/linked_mutex.dart b/lib/src/util/linked_mutex.dart new file mode 100644 index 0000000..2ed3d5d --- /dev/null +++ b/lib/src/util/linked_mutex.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:control/src/mutex.dart'; + +/// {@template linked_mutex} +/// A mutex implementation using a linked list of tasks. +/// This allows for synchronizing access to a critical section of code, +/// ensuring that only one task can execute the critical section at a time. +/// {@endtemplate} +class LinkedMutex implements Mutex { + /// Creates a new instance of the mutex. + /// + /// {@macro linked_mutex} + LinkedMutex(); + + /// The head of the linked list of mutex tasks. + _MutexTask? _head; + + /// Check if the mutex is currently locked. + @override + bool get locked => _head != null; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// Future(() async { + /// final unlock = await mutex.lock(); + /// try { + /// await criticalSection(i); + /// } finally { + /// unlock(); + /// } + /// }); + /// ``` + @override + Future lock() async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + prior.next = node; + await prior.future; + } + return () { + if (node.isCompleted) return; + node.complete(); + if (identical(_head, node)) _head = null; + }; + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + @override + Future synchronize(Future Function() action) async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + prior.next = node; + await prior.future; + } + try { + final result = await action(); + return result; + } on Object { + rethrow; + } finally { + node.complete(); + if (identical(_head, node)) _head = null; + } + } +} + +/// A task in the linked list of mutex tasks. +final class _MutexTask { + _MutexTask.sync() : _completer = Completer.sync(); + + final Completer _completer; + + /// Whether the task has been completed. + bool get isCompleted => _completer.isCompleted; + + /// The future that completes when the task is done. + Future get future => _completer.future; + + /// Executes the task. + /// After completion, it triggers the execution of the next task in the queue. + void complete() => _completer.complete(); + + /// Next task in the mutex queue. + _MutexTask? next; +} diff --git a/pubspec.yaml b/pubspec.yaml index 308e5ff..3ece184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: control description: "Simple state management for Flutter with concurrency support." -version: 0.2.0 +version: 1.0.0-dev.1 homepage: https://github.com/PlugFox/control @@ -34,15 +34,18 @@ platforms: # path: example.png environment: - sdk: '>=3.4.0 <4.0.0' - flutter: ">=3.16.0" + sdk: ">=3.9.0 <4.0.0" + flutter: ">=3.38.0" dependencies: flutter: sdk: flutter - meta: ^1.0.0 + meta: ^1.17.0 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + #benchmark_harness: any + #synchronized: any + #ffi: ^2.1.0 + flutter_lints: ^6.0.0 diff --git a/test/control_test.dart b/test/control_test.dart index 6fc123e..a0c9d2b 100644 --- a/test/control_test.dart +++ b/test/control_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'unit/handler_context_test.dart' as handler_context_test; +import 'unit/mutex_test.dart' as mutex_test; import 'unit/state_controller_test.dart' as state_controller_test; import 'widget/controller_scope_test.dart' as state_scope_test; import 'widget/state_consumer_test.dart' as state_consumer_test; @@ -11,6 +12,7 @@ void main() { group('unit', () { state_controller_test.main(); handler_context_test.main(); + mutex_test.main(); }); group('widget', () { diff --git a/test/unit/handler_context_test.dart b/test/unit/handler_context_test.dart index 4dce995..bb9f079 100644 --- a/test/unit/handler_context_test.dart +++ b/test/unit/handler_context_test.dart @@ -1,88 +1,73 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:math' as math; import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; void main() => group('HandlerContext', () { - test('FakeControllers', () async { - final controllers = <_FakeControllerBase>[ - _FakeControllerSequential(), - _FakeControllerDroppable(), - _FakeControllerConcurrent(), - ]; - for (final controller in controllers) { - final observer = Controller.observer = _FakeControllerObserver(); - expect(controller.isProcessing, isFalse); - expect(observer.lastContext, isNull); - expect(observer.lastStateContext, isNull); - expect(observer.lastErrorContext, isNull); - expect(Controller.context, isNull); - - // After the normal event is called, the context should be available. - final value = math.Random().nextDouble(); - HandlerContext? lastContext; - controller.event( + test('FakeControllers', () async { + final controllers = <_FakeControllerBase>[ + _FakeControllerSequential(), + _FakeControllerDroppable(), + _FakeControllerConcurrent(), + ]; + for (final controller in controllers) { + final observer = Controller.observer = _FakeControllerObserver(); + expect(controller.isProcessing, isFalse); + expect(observer.lastContext, isNull); + expect(observer.lastStateContext, isNull); + expect(observer.lastErrorContext, isNull); + expect(Controller.context, isNull); + + // After the normal event is called, the context should be available. + final value = math.Random().nextDouble(); + HandlerContext? lastContext; + controller + .event( meta: {'double': value}, out: (ctx) => lastContext = ctx, - ).ignore(); - expect(controller.isProcessing, isTrue); - expect(lastContext, isNotNull); - await expectLater(lastContext!.done, completes); - // Event should be done by now. - expect(lastContext!.isDone, isTrue); - expect( - lastContext, - allOf( - isNotNull, - same(observer.lastContext), - same(observer.lastStateContext), - isA() - .having( - (ctx) => ctx.name, - 'name', - 'event', - ) - .having( - (ctx) => ctx.meta['double'], - 'meta should contain double', - equals(value), - ) - .having( - (ctx) => ctx.meta['started_at'], - 'meta should contain started_at', - allOf( - isNotNull, - isA(), - ), - ) - .having( - (ctx) => ctx.meta['duration'], - 'meta should contain duration', - allOf( - isNotNull, - isA(), - isNot(Duration.zero), - ), - ) - .having( - (ctx) => ctx.controller, - 'controller', - same(controller), - ) - .having( - (ctx) => ctx.isDone, - 'isDone', - isTrue, - ), - ), - ); - expect(observer.lastErrorContext, isNull); - expect(Controller.context, isNull); - - controller.dispose(); - } - }); - }); + ) + .ignore(); + expect(controller.isProcessing, isTrue); + expect(lastContext, isNotNull); + await expectLater(lastContext!.done, completes); + // Event should be done by now. + expect(lastContext!.isDone, isTrue); + expect( + lastContext, + allOf( + isNotNull, + same(observer.lastContext), + same(observer.lastStateContext), + isA() + .having((ctx) => ctx.name, 'name', 'event') + .having( + (ctx) => ctx.meta['double'], + 'meta should contain double', + equals(value), + ) + .having( + (ctx) => ctx.meta['started_at'], + 'meta should contain started_at', + allOf(isNotNull, isA()), + ) + .having( + (ctx) => ctx.meta['duration'], + 'meta should contain duration', + allOf(isNotNull, isA(), isNot(Duration.zero)), + ) + .having((ctx) => ctx.controller, 'controller', same(controller)) + .having((ctx) => ctx.isDone, 'isDone', isTrue), + ), + ); + expect(observer.lastErrorContext, isNull); + expect(Controller.context, isNull); + + controller.dispose(); + } + }); +}); final class _FakeControllerObserver implements IControllerObserver { HandlerContext? lastContext; @@ -90,10 +75,14 @@ final class _FakeControllerObserver implements IControllerObserver { HandlerContext? lastErrorContext; @override - void onCreate(Controller controller) {/* ignore */} + void onCreate(Controller controller) { + /* ignore */ + } @override - void onDispose(Controller controller) {/* ignore */} + void onDispose(Controller controller) { + /* ignore */ + } @override void onHandler(HandlerContext context) { @@ -115,35 +104,31 @@ final class _FakeControllerObserver implements IControllerObserver { } } -abstract base class _FakeControllerBase extends StateController { +abstract class _FakeControllerBase extends StateController { _FakeControllerBase() : super(initialState: false); Future event({ Map? meta, void Function(HandlerContext context)? out, - }) => - handle( - () async { + }) => handle( + () async { + out?.call(Controller.context!); + final stopwatch = Stopwatch()..start(); + try { + setState(false); + await Future.delayed(Duration.zero); + () { out?.call(Controller.context!); - final stopwatch = Stopwatch()..start(); - try { - setState(false); - await Future.delayed(Duration.zero); - () { - out?.call(Controller.context!); - }(); - setState(true); - Controller.context?.meta['duration'] = stopwatch.elapsed; - } finally { - stopwatch.stop(); - } - }, - name: 'event', - meta: { - ...?meta, - 'started_at': DateTime.now(), - }, - ); + }(); + setState(true); + Controller.context?.meta['duration'] = stopwatch.elapsed; + } finally { + stopwatch.stop(); + } + }, + name: 'event', + meta: {...?meta, 'started_at': DateTime.now()}, + ); } final class _FakeControllerSequential = _FakeControllerBase diff --git a/test/unit/mutex_test.dart b/test/unit/mutex_test.dart new file mode 100644 index 0000000..f33a8d9 --- /dev/null +++ b/test/unit/mutex_test.dart @@ -0,0 +1,1137 @@ +@Timeout(Duration(milliseconds: 1500)) +library; + +import 'dart:async'; + +import 'package:control/control.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => group('Mutex', () { + test('Creation', () { + expect(Mutex.new, returnsNormally); + expect( + Mutex(), + isA() + .having((m) => m.locked, 'locked', isFalse) + .having((m) => m.synchronize, 'synchronize', isA()) + .having((m) => m.lock, 'lock', isA()), + ); + }); + + group('synchronize', () { + test('executes action', () async { + final mutex = Mutex(); + var executed = false; + + await mutex.synchronize(() async { + executed = true; + }); + + expect(executed, isTrue); + expect(mutex.locked, isFalse); + }); + + test('returns action result', () async { + final mutex = Mutex(); + + final result = await mutex.synchronize(() async => 42); + + expect(result, equals(42)); + }); + + test('serializes multiple calls', () async { + final mutex = Mutex(); + var counter = 0; + final results = []; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + results.add(counter); + }), + ); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(results.length, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('maintains FIFO order', () async { + final mutex = Mutex(); + final order = []; + + final futures = List.generate( + 10, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 10)); + order.add(i); + }), + ); + + await Future.wait(futures); + + expect(order, equals([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); + expect(mutex.locked, isFalse); + }); + + test('unlocks on exception', () async { + final mutex = Mutex(); + + expect( + () => mutex.synchronize(() async { + throw Exception('test error'); + }), + throwsException, + ); + + await Future.delayed(const Duration(milliseconds: 10)); + + expect(mutex.locked, isFalse); + + // Should work after exception + var recovered = false; + await mutex.synchronize(() async { + recovered = true; + }); + expect(recovered, isTrue); + }); + + test('propagates exception', () async { + final mutex = Mutex(); + final exception = Exception('test error'); + + expect( + mutex.synchronize(() async => throw exception), + throwsA(equals(exception)), + ); + }); + + test('handles synchronous exceptions', () async { + final mutex = Mutex(); + + expect( + mutex.synchronize(() => throw StateError('sync error')), + throwsStateError, + ); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isFalse); + }); + + test('queues waiting tasks', () async { + final mutex = Mutex(); + final events = []; + + // Start first task + final future1 = mutex.synchronize(() async { + events.add('start-1'); + await Future.delayed(const Duration(milliseconds: 50)); + events.add('end-1'); + }); + + // Give it time to start + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + + // Queue second task + final future2 = mutex.synchronize(() async { + events.add('start-2'); + await Future.delayed(const Duration(milliseconds: 10)); + events.add('end-2'); + }); + + // Second task should be waiting + expect(mutex.locked, isTrue); + + await Future.wait([future1, future2]); + + expect(events, equals(['start-1', 'end-1', 'start-2', 'end-2'])); + expect(mutex.locked, isFalse); + }); + + test('handles nested synchronize', () async { + final mutex = Mutex(); + var counter = 0; + + await mutex.synchronize(() async { + counter++; + // This will deadlock, but that's expected behavior + // Just test single level + }); + + expect(counter, equals(1)); + expect(mutex.locked, isFalse); + }); + + test('completes in order with different execution times', () async { + final mutex = Mutex(); + final completionOrder = []; + + final futures = >[ + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 30)); + completionOrder.add(1); + }), + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 10)); + completionOrder.add(2); + }), + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 20)); + completionOrder.add(3); + }), + ]; + + await Future.wait(futures); + + expect(completionOrder, equals([1, 2, 3])); + expect(mutex.locked, isFalse); + }); + }); + + group('lock', () { + test('returns unlock function', () async { + final mutex = Mutex(); + + final unlock = await mutex.lock(); + + expect(unlock, isA()); + expect(mutex.locked, isTrue); + + unlock(); + expect(mutex.locked, isFalse); + }); + + test('allows manual lock/unlock', () async { + final mutex = Mutex(); + var counter = 0; + + final unlock = await mutex.lock(); + try { + counter++; + await Future.delayed(const Duration(milliseconds: 10)); + counter++; + } finally { + unlock(); + } + + expect(counter, equals(2)); + expect(mutex.locked, isFalse); + }); + + test('serializes multiple locks', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate(100, (i) async { + final unlock = await mutex.lock(); + try { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + } finally { + unlock(); + } + }); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('unlock is idempotent', () async { + final mutex = Mutex(); + + final unlock = await mutex.lock(); + expect(mutex.locked, isTrue); + + unlock(); + expect(mutex.locked, isFalse); + + unlock(); // Second call + expect(mutex.locked, isFalse); + + unlock(); // Third call + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + + test('maintains FIFO order', () async { + final mutex = Mutex(); + final lockOrder = []; + + final unlockA = await mutex.lock(); + lockOrder.add('A'); + + final futureB = () async { + final unlock = await mutex.lock(); + lockOrder.add('B'); + await Future.delayed(const Duration(microseconds: 10)); + unlock(); + }(); + + final futureC = () async { + final unlock = await mutex.lock(); + lockOrder.add('C'); + await Future.delayed(const Duration(microseconds: 10)); + unlock(); + }(); + + await Future.delayed(const Duration(milliseconds: 10)); + + unlockA(); + + await Future.wait([futureB, futureC]); + + expect(lockOrder, equals(['A', 'B', 'C'])); + expect(mutex.locked, isFalse); + }); + + test('handles exception with finally unlock', () async { + final mutex = Mutex(); + + try { + final unlock = await mutex.lock(); + try { + throw Exception('test error'); + } finally { + unlock(); + } + } on Object catch (_) { + // Expected + } + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isFalse); + + // Should work after exception + final unlock = await mutex.lock(); + expect(mutex.locked, isTrue); + unlock(); + }); + + test('works after forgotten unlock', () async { + final mutex = Mutex(); + + // Forget to unlock (bad practice, but should handle) + await mutex.lock(); + expect(mutex.locked, isTrue); + + // New lock should wait + var acquired = false; + unawaited( + mutex.lock().then((unlock) { + acquired = true; + unlock(); + }), + ); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(acquired, isFalse); // Still waiting + + // This would hang forever in real code + // In tests we just verify the behavior + }); + + test('unlock after lock allows immediate re-lock', () async { + final mutex = Mutex(); + + final unlock1 = await mutex.lock(); + unlock1(); + + final unlock2 = await mutex.lock(); + expect(mutex.locked, isTrue); + unlock2(); + expect(mutex.locked, isFalse); + }); + }); + + group('mixed lock and synchronize', () { + test('interleaves correctly', () async { + final mutex = Mutex(); + final order = []; + + final futures = >[ + mutex.synchronize(() async { + order.add('sync-1'); + await Future.delayed(const Duration(microseconds: 10)); + }), + () async { + final unlock = await mutex.lock(); + try { + order.add('lock-1'); + await Future.delayed(const Duration(microseconds: 10)); + } finally { + unlock(); + } + }(), + mutex.synchronize(() async { + order.add('sync-2'); + await Future.delayed(const Duration(microseconds: 10)); + }), + () async { + final unlock = await mutex.lock(); + try { + order.add('lock-2'); + await Future.delayed(const Duration(microseconds: 10)); + } finally { + unlock(); + } + }(), + ]; + + await Future.wait(futures); + + expect(order, equals(['sync-1', 'lock-1', 'sync-2', 'lock-2'])); + expect(mutex.locked, isFalse); + }); + + test('maintains single execution guarantee', () async { + final mutex = Mutex(); + var concurrent = 0; + var maxConcurrent = 0; + + final futures = >[]; + + for (var i = 0; i < 50; i++) { + if (i % 2 == 0) { + futures.add( + mutex.synchronize(() async { + concurrent++; + maxConcurrent = maxConcurrent > concurrent + ? maxConcurrent + : concurrent; + await Future.delayed(const Duration(microseconds: 10)); + concurrent--; + }), + ); + } else { + futures.add(() async { + final unlock = await mutex.lock(); + try { + concurrent++; + maxConcurrent = maxConcurrent > concurrent + ? maxConcurrent + : concurrent; + await Future.delayed(const Duration(microseconds: 10)); + concurrent--; + } finally { + unlock(); + } + }()); + } + } + + await Future.wait(futures); + + expect(maxConcurrent, equals(1)); + expect(concurrent, equals(0)); + expect(mutex.locked, isFalse); + }); + }); + + group('edge cases', () { + test('handles immediate completion', () async { + final mutex = Mutex(); + + await mutex.synchronize(() async {}); + + expect(mutex.locked, isFalse); + }); + + test('handles synchronous action', () async { + final mutex = Mutex(); + var value = 0; + + await mutex.synchronize(() => Future.value(42)); + value = await mutex.synchronize(() => Future.value(100)); + + expect(value, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('handles multiple rapid synchronizations', () async { + final mutex = Mutex(); + final results = []; + + for (var i = 0; i < 1000; i++) { + unawaited( + mutex.synchronize(() async { + results.add(i); + }), + ); + } + + // Wait for all to complete + await Future.delayed(const Duration(milliseconds: 100)); + await mutex.synchronize(() async {}); // Barrier + + expect(results.length, equals(1000)); + expect(mutex.locked, isFalse); + }); + + test('handles zero delay', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + await Future.delayed(Duration.zero); + counter++; + }), + ); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('expectLater locked status during execution', () async { + final mutex = Mutex(); + + final future = mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + + await Future.delayed(const Duration(milliseconds: 10)); + await expectLater(mutex.locked, isTrue); + + await future; + await expectLater(mutex.locked, isFalse); + }); + + test('expectLater sequential execution', () async { + final mutex = Mutex(); + final stream = StreamController(); + + mutex + ..synchronize(() async { + stream.add(1); + await Future.delayed(const Duration(milliseconds: 20)); + stream.add(2); + }).ignore() + ..synchronize(() async { + stream.add(3); + await Future.delayed(const Duration(milliseconds: 20)); + stream.add(4); + }).ignore(); + + await expectLater(stream.stream, emitsInOrder([1, 2, 3, 4])); + + await stream.close(); + }); + + test('handles null result', () async { + final mutex = Mutex(); + + final result = await mutex.synchronize(() async => null); + + expect(result, isNull); + }); + + test('preserves generic type', () async { + final mutex = Mutex(); + + final stringResult = await mutex.synchronize(() async => 'test'); + final intResult = await mutex.synchronize(() async => 42); + final boolResult = await mutex.synchronize(() async => true); + + expect(stringResult, isA()); + expect(intResult, isA()); + expect(boolResult, isA()); + }); + + test('completes with void result', () async { + final mutex = Mutex(); + + await mutex.synchronize(() async {}); + + expect(mutex.locked, isFalse); + }); + + test('survives rapid lock/unlock cycles', () async { + final mutex = Mutex(); + + for (var i = 0; i < 100; i++) { + final unlock = await mutex.lock(); + unlock(); + } + + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('stress tests', () { + test('handles 1000 concurrent operations', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 1000, + (i) => mutex.synchronize(() async { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + }), + ); + + await Future.wait(futures); + + expect(counter, equals(1000)); + expect(mutex.locked, isFalse); + }); + + test('handles mixed operations under load', () async { + final mutex = Mutex(); + var syncCounter = 0; + var lockCounter = 0; + + final futures = List.generate(500, (i) { + if (i % 2 == 0) { + return mutex.synchronize(() async { + await Future.delayed(Duration.zero); + syncCounter++; + }); + } else { + return () async { + final unlock = await mutex.lock(); + try { + await Future.delayed(Duration.zero); + lockCounter++; + } finally { + unlock(); + } + }(); + } + }); + + await Future.wait(futures); + + expect(syncCounter, equals(250)); + expect(lockCounter, equals(250)); + expect(mutex.locked, isFalse); + }); + + test('handles rapid lock/unlock without delays', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate(200, (i) async { + final unlock = await mutex.lock(); + counter++; + unlock(); + }); + + await Future.wait(futures); + + expect(counter, equals(200)); + expect(mutex.locked, isFalse); + }); + + test('handles alternating sync and lock patterns', () async { + final mutex = Mutex(); + final pattern = []; + + final futures = >[]; + for (var i = 0; i < 100; i++) { + if (i % 3 == 0) { + futures.add( + mutex.synchronize(() async { + pattern.add('S'); + }), + ); + } else if (i % 3 == 1) { + futures.add(() async { + final unlock = await mutex.lock(); + pattern.add('L'); + unlock(); + }()); + } else { + futures.add( + mutex.synchronize(() async { + await Future.delayed(Duration.zero); + pattern.add('S'); + }), + ); + } + } + + await Future.wait(futures); + + expect(pattern.length, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('concurrent stress with exceptions', () async { + final mutex = Mutex(); + var successCount = 0; + var errorCount = 0; + + final futures = List.generate(100, (i) async { + try { + await mutex.synchronize(() async { + if (i % 10 == 0) { + throw Exception('Intentional error $i'); + } + successCount++; + }); + } on Object catch (_) { + errorCount++; + } + }); + + await Future.wait(futures); + + expect(successCount, equals(90)); + expect(errorCount, equals(10)); + expect(mutex.locked, isFalse); + }); + }); + + group('timeout and cancellation', () { + test('handles timeout on synchronize', () async { + final mutex = Mutex(); + + // Lock mutex + final unlock = await mutex.lock(); + + var timedOut = false; + try { + await mutex + .synchronize(() async => 42) + .timeout(const Duration(milliseconds: 50)); + } on TimeoutException { + timedOut = true; + } + + expect(timedOut, isTrue); + + // Unlock and verify recovery + unlock(); + await Future.delayed(const Duration(milliseconds: 10)); + + final result = await mutex.synchronize(() async => 100); + expect(result, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('handles multiple timeouts', () async { + final mutex = Mutex(); + + final unlock = await mutex.lock(); + + var timeoutCount = 0; + for (var i = 0; i < 5; i++) { + try { + await mutex + .synchronize(() async => i) + .timeout(const Duration(milliseconds: 50)); + } on TimeoutException { + timeoutCount++; + } + } + + expect(timeoutCount, equals(5)); + + unlock(); + + // Should still work after timeouts + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('complex scenarios', () { + test('producer-consumer pattern', () async { + final mutex = Mutex(); + final queue = []; + var produced = 0; + + // Producer + final producer = Future(() async { + for (var i = 0; i < 50; i++) { + await mutex.synchronize(() async { + queue.add(i); + produced++; + }); + } + }); + + // Consumer + final consumer = Future(() async { + for (var i = 0; i < 50; i++) { + await mutex.synchronize(() async { + if (queue.isNotEmpty) { + queue.removeAt(0); + } + }); + await Future.delayed(Duration.zero); + } + }); + + await Future.wait([producer, consumer]); + + expect(produced, equals(50)); + expect(mutex.locked, isFalse); + }); + + test('reader-writer simulation with single mutex', () async { + final mutex = Mutex(); + var data = 0; + final readValues = []; + + final writers = List.generate( + 10, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 10)); + data++; + }), + ); + + final readers = List.generate( + 20, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 5)); + readValues.add(data); + }), + ); + + await Future.wait([...writers, ...readers]); + + expect(data, equals(10)); + expect(readValues.length, equals(20)); + expect(mutex.locked, isFalse); + }); + + test('cascading lock acquisitions', () async { + final mutex = Mutex(); + final order = []; + + Future cascade(int depth) async { + if (depth <= 0) return; + await mutex.synchronize(() async { + order.add(depth); + await Future.delayed(const Duration(microseconds: 10)); + }); + unawaited(cascade(depth - 1)); + } + + await cascade(10); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(order.length, equals(10)); + expect(mutex.locked, isFalse); + }); + + test('mutex with conditional logic', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + if (counter % 2 == 0) { + counter += 2; + } else { + counter += 1; + } + await Future.delayed(Duration.zero); + }), + ); + + await Future.wait(futures); + + expect(counter, greaterThan(0)); + expect(mutex.locked, isFalse); + }); + + test('batch processing pattern', () async { + final mutex = Mutex(); + final batches = >[]; + final items = List.generate(100, (i) => i); + + const batchSize = 10; + for (var i = 0; i < items.length; i += batchSize) { + await mutex.synchronize(() async { + final end = (i + batchSize).clamp(0, items.length); + batches.add(items.sublist(i, end)); + await Future.delayed(const Duration(microseconds: 10)); + }); + } + + expect(batches.length, equals(10)); + expect(batches.expand((b) => b).length, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('interleaved fast and slow operations', () async { + final mutex = Mutex(); + var fastCount = 0; + var slowCount = 0; + + final futures = List.generate(50, (i) { + if (i % 2 == 0) { + // Fast operation + return mutex.synchronize(() async { + fastCount++; + }); + } else { + // Slow operation + return mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 50)); + slowCount++; + }); + } + }); + + await Future.wait(futures); + + expect(fastCount, equals(25)); + expect(slowCount, equals(25)); + expect(mutex.locked, isFalse); + }); + + test('lock acquisition with early completion', () async { + final mutex = Mutex(); + final completions = []; + + final future1 = mutex.synchronize(() async { + completions + ..add('start-1') + ..add('end-1'); + }); + + final future2 = mutex.synchronize(() async { + completions.add('start-2'); + await Future.delayed(const Duration(milliseconds: 20)); + completions.add('end-2'); + }); + + final future3 = mutex.synchronize(() async { + completions + ..add('start-3') + ..add('end-3'); + }); + + await Future.wait([future1, future2, future3]); + + expect( + completions, + equals(['start-1', 'end-1', 'start-2', 'end-2', 'start-3', 'end-3']), + ); + expect(mutex.locked, isFalse); + }); + + test('multiple unlocks from different contexts', () async { + final mutex = Mutex(); + final unlocks = []; + + for (var i = 0; i < 5; i++) { + final unlock = await mutex.lock(); + unlocks.add(unlock); + expect(mutex.locked, isTrue); + unlock(); + expect(mutex.locked, isFalse); + } + + // Call old unlocks (should be safe) + for (final unlock in unlocks) { + unlock(); + } + + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('state verification', () { + test('locked state transitions', () async { + final mutex = Mutex(); + final states = [mutex.locked]; // false + + final unlock = await mutex.lock(); + states.add(mutex.locked); // true + + unlock(); + states.add(mutex.locked); // false + + await mutex.synchronize(() async { + states.add(mutex.locked); // true (during execution) + }); + states.add(mutex.locked); // false + + expect(states, equals([false, true, false, true, false])); + }); + + test('locked during nested operations', () async { + final mutex = Mutex(); + + await mutex.synchronize(() async { + expect(mutex.locked, isTrue); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + }); + + expect(mutex.locked, isFalse); + }); + + test('locked with concurrent waiters', () async { + final mutex = Mutex(); + + final unlock1 = await mutex.lock(); + expect(mutex.locked, isTrue); + + final future2 = mutex.lock(); + final future3 = mutex.lock(); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + + unlock1(); + final unlock2 = await future2; + expect(mutex.locked, isTrue); + + unlock2(); + final unlock3 = await future3; + expect(mutex.locked, isTrue); + + unlock3(); + expect(mutex.locked, isFalse); + }); + + test('state consistency after errors', () async { + final mutex = Mutex(); + + // Error in synchronize + try { + await mutex.synchronize(() async { + throw StateError('test'); + }); + } on Object catch (_) { + // Expected + } + expect(mutex.locked, isFalse); + + // Error in lock + try { + final unlock = await mutex.lock(); + try { + throw Exception('error'); + } finally { + unlock(); + } + } on Object catch (_) { + // Expected + } + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + + test('multiple mutex instances are independent', () async { + final mutex1 = Mutex(); + final mutex2 = Mutex(); + + final unlock1 = await mutex1.lock(); + expect(mutex1.locked, isTrue); + expect(mutex2.locked, isFalse); + + final unlock2 = await mutex2.lock(); + expect(mutex1.locked, isTrue); + expect(mutex2.locked, isTrue); + + unlock1(); + expect(mutex1.locked, isFalse); + expect(mutex2.locked, isTrue); + + unlock2(); + expect(mutex1.locked, isFalse); + expect(mutex2.locked, isFalse); + }); + }); + + group('error propagation', () { + test('preserves stack trace', () async { + final mutex = Mutex(); + + try { + await mutex.synchronize(() async { + throw Exception('original error'); + }); + } on Object catch (e, stackTrace) { + expect(e.toString(), contains('original error')); + expect(stackTrace.toString(), isNotEmpty); + return; // Expected error + } + }); + + test('different error types', () async { + final mutex = Mutex(); + + expect( + mutex.synchronize(() async => throw ArgumentError('test')), + throwsArgumentError, + ); + + expect( + mutex.synchronize(() async => throw StateError('test')), + throwsStateError, + ); + + expect( + mutex.synchronize(() async => throw const FormatException('test')), + throwsFormatException, + ); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(mutex.locked, isFalse); + }); + + test('error does not affect queued operations', () async { + final mutex = Mutex(); + final results = []; + + final futures = >[ + mutex.synchronize(() async { + results.add('ok-1'); + }), + () async { + results.add('error'); + try { + throw Exception('test error'); + } on Object catch (_) { + // Ignore + } + }(), + mutex.synchronize(() async { + results.add('ok-2'); + }), + ]; + + await Future.wait(futures); + + expect(results, equals(['ok-1', 'error', 'ok-2'])); + expect(mutex.locked, isFalse); + }); + }); +}); diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 1bdbb16..7ca3c73 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -1,4 +1,5 @@ // ignore_for_file: unnecessary_lambdas, unused_element +// ignore_for_file: deprecated_member_use_from_same_package import 'dart:async'; @@ -7,431 +8,634 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; void main() => group('StateController', () { - _$concurrencyGroup(); - _$exceptionalGroup(); - _$assertionGroup(); - _$methodsGroup(); - _$onErrorGroup(); - }); + _$concurrencyGroup(); + _$genericHandleGroup(); + _$exceptionalGroup(); + _$assertionGroup(); + _$methodsGroup(); + _$onErrorGroup(); +}); void _$concurrencyGroup() => group('concurrency', () { - test('sequential', () async { - final controller = _FakeControllerSequential(); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(0)); - expect(controller.subscribers, equals(0)); - expect(controller.isDisposed, isFalse); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); - expect(controller.isProcessing, isTrue); - await expectLater(done, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.subscribers, equals(0)); - expect(() => controller.addListener(() {}), returnsNormally); - expect(controller.subscribers, equals(1)); - controller.dispose(); - expect(controller.subscribers, equals(0)); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.isDisposed, isTrue); - expect(() => controller.removeListener(() {}), returnsNormally); - }); - - test('droppable', () async { - final controller = _FakeControllerDroppable(); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(0)); - expect(controller.subscribers, equals(0)); - expect(controller.isDisposed, isFalse); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); - expect(controller.isProcessing, isTrue); - await expectLater(done, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(1)); - expect(controller.subscribers, equals(0)); - expect(() => controller.addListener(() {}), returnsNormally); - expect(controller.subscribers, equals(1)); - controller.dispose(); - expect(controller.subscribers, equals(0)); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(1)); - expect(controller.isDisposed, isTrue); - expect(() => controller.removeListener(() {}), returnsNormally); - }); - - test('concurrent', () async { - final controller = _FakeControllerConcurrent(); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(0)); - expect(controller.subscribers, equals(0)); - expect(controller.isDisposed, isFalse); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); - expect(controller.isProcessing, isTrue); - await expectLater(done, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.subscribers, equals(0)); - expect(() => controller.addListener(() {}), returnsNormally); - expect(controller.subscribers, equals(1)); - controller.dispose(); - expect(controller.subscribers, equals(0)); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.isDisposed, isTrue); - expect(() => controller.removeListener(() {}), returnsNormally); - }); - }); + test('sequential', () async { + final controller = _FakeControllerSequential(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); + expect(controller.isProcessing, isTrue); + await expectLater(done, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(3)); + expect(controller.subscribers, equals(0)); + expect(() => controller.addListener(() {}), returnsNormally); + expect(controller.subscribers, equals(1)); + controller.dispose(); + expect(controller.subscribers, equals(0)); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(3)); + expect(controller.isDisposed, isTrue); + expect(() => controller.removeListener(() {}), returnsNormally); + }); + + test('droppable', () async { + final controller = _FakeControllerDroppable(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + + // Start first operation (will succeed) + final first = controller.add(1); + expect(controller.isProcessing, isTrue); + + // These will be dropped (controller is busy) + final dropped1 = controller.subtract(2); + final dropped2 = controller.add(4); + + // Dropped operations complete with null + await expectLater(dropped1, completion(isNull)); + await expectLater(dropped2, completion(isNull)); + + // First operation completes + await expectLater(first, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(1)); // Only first operation executed + expect(controller.subscribers, equals(0)); + expect(() => controller.addListener(() {}), returnsNormally); + expect(controller.subscribers, equals(1)); + controller.dispose(); + expect(controller.subscribers, equals(0)); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(1)); + expect(controller.isDisposed, isTrue); + expect(() => controller.removeListener(() {}), returnsNormally); + }); + + test('concurrent', () async { + final controller = _FakeControllerConcurrent(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); + expect(controller.isProcessing, isTrue); + await expectLater(done, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(3)); + expect(controller.subscribers, equals(0)); + expect(() => controller.addListener(() {}), returnsNormally); + expect(controller.subscribers, equals(1)); + controller.dispose(); + expect(controller.subscribers, equals(0)); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(3)); + expect(controller.isDisposed, isTrue); + expect(() => controller.removeListener(() {}), returnsNormally); + }); +}); void _$exceptionalGroup() => group('exceptional', () { - test('throws if dispose called multiple times', () { - final controller = _FakeControllerConcurrent()..dispose(); - expect(() => controller.dispose(), throwsA(isA())); - }); - - test('handles edge case of adding large values', () async { - const largeValue = 9223372036854775807; - final controller = _FakeControllerConcurrent(); - await expectLater(controller.add(largeValue), completes); - expect(controller.state, equals(largeValue)); - controller.dispose(); - }); - - test('handles edge case of subtracting large values', () async { - const largeNegativeValue = 9223372036854775807; - final controller = _FakeControllerConcurrent(); - await expectLater(controller.subtract(largeNegativeValue), completes); - expect(controller.state, equals(-largeNegativeValue)); - controller.dispose(); - }); - - test('processes multiple operations efficiently', () async { - final stopwatch = Stopwatch()..start(); - try { - final controller = _FakeControllerConcurrent(); - final done = Future.wait( - >[for (var i = 0; i < 1000; i++) controller.add(1)]); - await expectLater(done, completes); - expect(controller.state, equals(1000)); - controller.dispose(); - } finally { - debugPrint('${(stopwatch..stop()).elapsedMicroseconds} μs'); - } - }); - - test('should correctly manage multiple listeners', () { - final controller = _FakeControllerConcurrent(); - - void listener1() {} - void listener2() {} - - expect(controller.subscribers, equals(0)); - - controller - ..addListener(listener1) - ..addListener(listener2); - expect(controller.subscribers, equals(2)); - - controller.removeListener(listener1); - expect(controller.subscribers, equals(1)); - - controller.removeListener(listener2); - expect(controller.subscribers, equals(0)); - }); - }); + test('throws if dispose called multiple times', () { + final controller = _FakeControllerConcurrent()..dispose(); + expect(() => controller.dispose(), throwsA(isA())); + }); + + test('handles edge case of adding large values', () async { + const largeValue = 9223372036854775807; + final controller = _FakeControllerConcurrent(); + await expectLater(controller.add(largeValue), completes); + expect(controller.state, equals(largeValue)); + controller.dispose(); + }); + + test('handles edge case of subtracting large values', () async { + const largeNegativeValue = 9223372036854775807; + final controller = _FakeControllerConcurrent(); + await expectLater(controller.subtract(largeNegativeValue), completes); + expect(controller.state, equals(-largeNegativeValue)); + controller.dispose(); + }); + + test('processes multiple operations efficiently', () async { + final stopwatch = Stopwatch()..start(); + try { + final controller = _FakeControllerConcurrent(); + final done = Future.wait(>[ + for (var i = 0; i < 1000; i++) controller.add(1), + ]); + await expectLater(done, completes); + expect(controller.state, equals(1000)); + controller.dispose(); + } finally { + debugPrint('${(stopwatch..stop()).elapsedMicroseconds} μs'); + } + }); + + test('should correctly manage multiple listeners', () { + final controller = _FakeControllerConcurrent(); + + void listener1() {} + void listener2() {} + + expect(controller.subscribers, equals(0)); + + controller + ..addListener(listener1) + ..addListener(listener2); + expect(controller.subscribers, equals(2)); + + controller.removeListener(listener1); + expect(controller.subscribers, equals(1)); + + controller.removeListener(listener2); + expect(controller.subscribers, equals(0)); + }); +}); void _$assertionGroup() => group('assertion', () { - test('should assert when notifyListeners called on disposed controller', - () { - final controller = _FakeControllerSequential(); - controller.dispose(); // ignore: cascade_invocations - - expect(controller.isDisposed, isTrue); - - expect( - () => controller.addWithNotifyListeners(1), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('A _FakeControllerSequential was already disposed.'), - )), - ); - }); - test('should assert when addListener called on disposed controller', () { - final controller = _FakeControllerSequential(); - controller.dispose(); // ignore: cascade_invocations - - expect(controller.isDisposed, isTrue); - - void listener() {} - - expect( - () => controller.addListener(listener), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('A _FakeControllerSequential was already disposed.'), - )), - ); - }); - }); + test('should assert when notifyListeners called on disposed controller', () { + final controller = _FakeControllerSequential(); + controller.dispose(); // ignore: cascade_invocations + + expect(controller.isDisposed, isTrue); + + expect( + () => controller.addWithNotifyListeners(1), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('A _FakeControllerSequential was already disposed.'), + ), + ), + ); + }); + test('should assert when addListener called on disposed controller', () { + final controller = _FakeControllerSequential(); + controller.dispose(); // ignore: cascade_invocations + + expect(controller.isDisposed, isTrue); + + void listener() {} + + expect( + () => controller.addListener(listener), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('A _FakeControllerSequential was already disposed.'), + ), + ), + ); + }); +}); + +void _$genericHandleGroup() => group('generic handle', () { + test('returns value from handler', () async { + final controller = _FakeControllerConcurrent(); + final result = await controller.getValue(42); + expect(result, equals(42)); + controller.dispose(); + }); + + test('sequential returns value', () async { + final controller = _FakeControllerSequential(); + final result = await controller.getValue(100); + expect(result, equals(100)); + controller.dispose(); + }); + + test('droppable returns value when not busy', () async { + final controller = _FakeControllerDroppable(); + final result = await controller.getValue(77); + expect(result, equals(77)); + controller.dispose(); + }); + + test('droppable returns null when busy', () async { + final controller = _FakeControllerDroppable(); + // Start first operation + final first = controller.getValue(1); + // Try second operation while first is running - returns null + final second = await controller.getValue(2); + expect(second, isNull); // Dropped, returns null + // First operation completes with value + await expectLater(first, completion(1)); + controller.dispose(); + }); +}); void _$methodsGroup() => group('methods', () { - test('merge', () async { - final controllerOne = _FakeControllerSequential(); - final controllerTwo = _FakeControllerSequential(); - - final mergedListenable = - Controller.merge([controllerOne, controllerTwo]); - - // Check that the result is an object of type Listenable - expect(mergedListenable, isA()); - - // Check that subscribers to mergedListenable listen for changes - // in both controllers - var listenerCalled = 0; - mergedListenable.addListener(() => listenerCalled++); - - controllerOne.add(1).ignore(); - await Future.delayed(Duration.zero); - expect(listenerCalled, equals(1)); - - controllerTwo.add(1).ignore(); - await Future.delayed(Duration.zero); - expect(listenerCalled, equals(2)); - }); - - test('toStream', () async { - final controller = _FakeControllerConcurrent(); - expect(controller.toStream(), isA>()); - // ignore: unawaited_futures - expectLater( - controller.toStream(), - emitsInOrder([1, 0, -1, 2, emitsDone]), - ); - await expectLater( - Future.wait([ - controller.add(1), - controller.subtract(1), - controller.subtract(1), - controller.add(3), - ]), - completes, - ); - controller.dispose(); - }); - - test('toValueListenable', () async { - final controller = _FakeControllerConcurrent(); - final listenable = controller.toValueListenable(); - expect(listenable, isA>()); - expect(listenable.value, equals(controller.state)); - await expectLater( - Future.wait([ - controller.add(2), - controller.subtract(1), - ]), - completes, - ); - expect(listenable.value, equals(controller.state)); - final completer = Completer(); - listenable.addListener(completer.complete); - controller.add(1).ignore(); - await expectLater(completer.future, completes); - expect(completer.isCompleted, isTrue); - controller.dispose(); - }); - }); + test('merge', () async { + final controllerOne = _FakeControllerSequential(); + final controllerTwo = _FakeControllerSequential(); + + final mergedListenable = Controller.merge([controllerOne, controllerTwo]); + + // Check that the result is an object of type Listenable + expect(mergedListenable, isA()); + + // Check that subscribers to mergedListenable listen for changes + // in both controllers + var listenerCalled = 0; + mergedListenable.addListener(() => listenerCalled++); + + controllerOne.add(1).ignore(); + await Future.delayed(Duration.zero); + expect(listenerCalled, equals(1)); + + controllerTwo.add(1).ignore(); + await Future.delayed(Duration.zero); + expect(listenerCalled, equals(2)); + }); + + test('toStream', () async { + final controller = _FakeControllerConcurrent(); + expect(controller.toStream(), isA>()); + // ignore: unawaited_futures + expectLater( + controller.toStream(), + emitsInOrder([1, 0, -1, 2, emitsDone]), + ); + await expectLater( + Future.wait([ + controller.add(1), + controller.subtract(1), + controller.subtract(1), + controller.add(3), + ]), + completes, + ); + controller.dispose(); + }); + + test('toValueListenable', () async { + final controller = _FakeControllerConcurrent(); + final listenable = controller.toValueListenable(); + expect(listenable, isA>()); + expect(listenable.value, equals(controller.state)); + await expectLater( + Future.wait([controller.add(2), controller.subtract(1)]), + completes, + ); + expect(listenable.value, equals(controller.state)); + final completer = Completer(); + listenable.addListener(completer.complete); + controller.add(1).ignore(); + await expectLater(completer.future, completes); + expect(completer.isCompleted, isTrue); + controller.dispose(); + }); + + test('select', () async { + final controller = _FakeControllerConcurrent(); + + // Test selector without filter + final selected = controller.select((state) => 'value:$state'); + expect(selected, isA>()); + expect(selected.value, equals('value:0')); + + // Test that selector updates on state change + var listenerCallCount = 0; + void listener() => listenerCallCount++; + selected.addListener(listener); + + await controller.add(5); + expect(selected.value, equals('value:5')); + expect(listenerCallCount, equals(1)); + + await controller.subtract(3); + expect(selected.value, equals('value:2')); + expect(listenerCallCount, equals(2)); + + selected.removeListener(listener); + await controller.add(1); + expect(listenerCallCount, equals(2)); // Listener not called after removal + + controller.dispose(); + }); + + test('select with filter', () async { + final controller = _FakeControllerConcurrent(); + + // Test selector with filter - only notify if value changes + final selected = controller.select( + (state) => state > 0, + (prev, next) => prev != next, // Only notify if boolean value changes + ); + + expect(selected.value, equals(false)); // Initial state is 0 + + var listenerCallCount = 0; + void listener() => listenerCallCount++; + selected.addListener(listener); + + // Change from 0 to 5 (false to true) - should notify + await controller.add(5); + expect(selected.value, equals(true)); + expect(listenerCallCount, equals(1)); + + // Change from 5 to 10 (true to true) - should NOT notify due to filter + await controller.add(5); + expect(selected.value, equals(true)); + expect(listenerCallCount, equals(1)); // No change in boolean value + + // Change from 10 to -5 (true to false) - should notify + await controller.subtract(15); + expect(selected.value, equals(false)); + expect(listenerCallCount, equals(2)); + + selected.removeListener(listener); + controller.dispose(); + }); + + test('select on disposed controller', () async { + final controller = _FakeControllerConcurrent(); + final selected = controller.select((state) => 'value:$state'); + + void listener() {} + selected.addListener(listener); + + controller.dispose(); + + // Should not throw when adding listener to disposed controller's selected + expect(() => selected.addListener(() {}), returnsNormally); + expect(() => selected.removeListener(listener), returnsNormally); + }); + + test('select value accessed without subscription', () async { + final controller = _FakeControllerConcurrent(); + final selected = controller.select((state) => state * 2); + + // Access value before subscribing - should compute on-demand + expect(selected.value, equals(0)); + + await controller.add(5); + expect(selected.value, equals(10)); // Should compute from current state + + controller.dispose(); + }); + + test('select disposes cleanly', () async { + final controller = _FakeControllerConcurrent(); + final selected = controller.select((state) => state * 2); + + var callCount = 0; + void listener() => callCount++; + + // Add and remove listener multiple times + selected.addListener(listener); + await controller.add(1); + expect(callCount, equals(1)); + + selected.removeListener(listener); + await controller.add(2); + expect(callCount, equals(1)); // No change after removal + + // Add listener again + selected.addListener(listener); + await controller.add(3); + expect(callCount, equals(2)); + + // Dispose selected - should clean up subscription + if (selected is ChangeNotifier) { + (selected as ChangeNotifier).dispose(); + } + + controller.dispose(); + }); +}); void _$onErrorGroup() => group('onError', () { - group('sequential', () { - test( - 'should call onError and error callback ' - 'when an exception is thrown', () async { - final controller = _FakeControllerSequential(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() => doneCalled++; - - controller.makeError( - onError: () async { - onError(); - throw Exception(); - }, - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - test( - 'should execute handler, handle errors, ' - 'and call done callback within runZonedGuarded', () async { - final controller = _FakeControllerSequential(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() { - doneCalled++; - throw Exception(); - } - - controller.makeError( - onError: () async => onError(), - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - }); - group('droppable', () { - test( - 'should call onError and error callback ' - 'when an exception is thrown', () async { - final controller = _FakeControllerDroppable(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() => doneCalled++; - - controller.makeError( - onError: () async { - onError(); - throw Exception(); - }, - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - test( - 'should execute handler, handle errors, ' - 'and call done callback within runZonedGuarded', () async { - final controller = _FakeControllerDroppable(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() { - doneCalled++; - throw Exception(); - } - - controller.makeError( - onError: () async => onError(), - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - }); - group('concurrent', () { - test( - 'should call onError and error callback ' - 'when an exception is thrown', () async { - final controller = _FakeControllerConcurrent(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() => doneCalled++; - - controller.makeError( - onError: () async { - onError(); - throw Exception(); - }, - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - test( - 'should execute handler, handle errors, ' - 'and call done callback within runZonedGuarded', () async { - final controller = _FakeControllerConcurrent(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() { - doneCalled++; - throw Exception(); - } - - controller.makeError( - onError: () async => onError(), - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - }); + group('sequential', () { + test('should call onError and error callback ' + 'when an exception is thrown', () async { + final controller = _FakeControllerSequential(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() => doneCalled++; + + controller.makeError( + onError: () async { + onError(); + throw Exception(); + }, + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + test('should execute handler, handle errors, ' + 'and call done callback within runZonedGuarded', () async { + final controller = _FakeControllerSequential(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() { + doneCalled++; + throw Exception(); + } + + controller.makeError( + onError: () async => onError(), + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); }); + }); + group('droppable', () { + test('should call onError and error callback ' + 'when an exception is thrown', () async { + final controller = _FakeControllerDroppable(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() => doneCalled++; + + controller.makeError( + onError: () async { + onError(); + throw Exception(); + }, + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + test('should execute handler, handle errors, ' + 'and call done callback within runZonedGuarded', () async { + final controller = _FakeControllerDroppable(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() { + doneCalled++; + throw Exception(); + } + + controller.makeError( + onError: () async => onError(), + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + }); + group('concurrent', () { + test('should call onError and error callback ' + 'when an exception is thrown', () async { + final controller = _FakeControllerConcurrent(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() => doneCalled++; + + controller.makeError( + onError: () async { + onError(); + throw Exception(); + }, + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + test('should execute handler, handle errors, ' + 'and call done callback within runZonedGuarded', () async { + final controller = _FakeControllerConcurrent(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() { + doneCalled++; + throw Exception(); + } + + controller.makeError( + onError: () async => onError(), + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + + test('should call observer onCreate with error handling', () async { + final oldObserver = Controller.observer; + var createCalled = false; -abstract base class _FakeControllerBase extends StateController { + // Observer that throws on onCreate + Controller.observer = _SimpleTestObserver( + onCreateCallback: () { + createCalled = true; + throw Exception('onCreate error'); + }, + ); + + // Create controller - should not throw despite observer error + final controller = _FakeControllerConcurrent(); + expect(createCalled, isTrue); + + Controller.observer = oldObserver; + controller.dispose(); + }); + + test('should call observer onDispose with error handling', () async { + final controller = _FakeControllerConcurrent(); + + final oldObserver = Controller.observer; + var disposeCalled = false; + + // Observer that throws on onDispose + Controller.observer = _SimpleTestObserver( + onDisposeCallback: () { + disposeCalled = true; + throw Exception('onDispose error'); + }, + ); + + // Dispose controller - should not throw despite observer error + controller.dispose(); + expect(disposeCalled, isTrue); + + Controller.observer = oldObserver; + }); + }); +}); + +abstract class _FakeControllerBase extends StateController { _FakeControllerBase({int? initialState}) - : super(initialState: initialState ?? 0); + : super(initialState: initialState ?? 0); Future add(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state + value); - }); + await Future.delayed(Duration.zero); + setState(state + value); + }); Future subtract(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state - value); - }); + await Future.delayed(Duration.zero); + setState(state - value); + }); + + /// Test generic handle - returns a value + Future getValue(int value) => handle(() async { + await Future.delayed(Duration.zero); + return value; + }); } final class _FakeControllerSequential extends _FakeControllerBase @@ -441,45 +645,70 @@ final class _FakeControllerSequential extends _FakeControllerBase notifyListeners(); } - void makeError({ - void Function()? onError, - void Function()? onDone, - }) => - handle( - () async { - throw Exception(); - }, - error: (_, __) async => onError?.call(), - done: () async => onDone?.call(), - ); + void makeError({void Function()? onError, void Function()? onDone}) => handle( + () async { + throw Exception(); + }, + error: (_, _) async => onError?.call(), + done: () async => onDone?.call(), + ); } final class _FakeControllerDroppable extends _FakeControllerBase with DroppableControllerHandler { - void makeError({ - void Function()? onError, - void Function()? onDone, - }) => - handle( - () async { - throw Exception(); - }, - error: (_, __) async => onError?.call(), - done: () async => onDone?.call(), - ); + void makeError({void Function()? onError, void Function()? onDone}) => handle( + () async { + throw Exception(); + }, + error: (_, _) async => onError?.call(), + done: () async => onDone?.call(), + ); } final class _FakeControllerConcurrent extends _FakeControllerBase with ConcurrentControllerHandler { - void makeError({ - void Function()? onError, - void Function()? onDone, - }) => - handle( - () async { - throw Exception(); - }, - error: (_, __) async => onError?.call(), - done: () async => onDone?.call(), - ); + void makeError({void Function()? onError, void Function()? onDone}) => handle( + () async { + throw Exception(); + }, + error: (_, _) async => onError?.call(), + done: () async => onDone?.call(), + ); +} + +final class _SimpleTestObserver implements IControllerObserver { + _SimpleTestObserver({ + this.onCreateCallback, + this.onDisposeCallback, + this.onErrorCallback, // ignore: unused_element_parameter + }); + + final void Function()? onCreateCallback; + final void Function()? onDisposeCallback; + final void Function(Object error)? onErrorCallback; + + @override + void onCreate(Controller controller) { + onCreateCallback?.call(); + } + + @override + void onDispose(Controller controller) { + onDisposeCallback?.call(); + } + + @override + void onHandler(HandlerContext context) {} + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) {} + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + onErrorCallback?.call(error); + } } diff --git a/test/util/test_util.dart b/test/util/test_util.dart index 26875f8..92a88d4 100644 --- a/test/util/test_util.dart +++ b/test/util/test_util.dart @@ -6,34 +6,30 @@ import 'package:flutter/material.dart'; abstract final class TestUtil { /// Basic wrapper for the current widgets. static Widget appContext({required Widget child, Size? size}) => MediaQuery( - data: MediaQueryData(size: size ?? const Size(800, 600)), - child: Directionality( - textDirection: TextDirection.ltr, - child: Material( - elevation: 0, - child: DefaultSelectionStyle( - child: ScaffoldMessenger( - child: HeroControllerScope.none( - child: Navigator( - pages: >[ - MaterialPage( - child: Scaffold( - body: SafeArea( - child: Center( - child: child, - ), - ), - ), - ), - ], - onDidRemovePage: (route) => route.canPop, + data: MediaQueryData(size: size ?? const Size(800, 600)), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + elevation: 0, + child: DefaultSelectionStyle( + child: ScaffoldMessenger( + child: HeroControllerScope.none( + child: Navigator( + pages: >[ + MaterialPage( + child: Scaffold( + body: SafeArea(child: Center(child: child)), + ), ), - ), + ], + onDidRemovePage: (route) => route.canPop, ), ), ), ), - ); + ), + ), + ); } /// Base fake controller for testing. @@ -42,12 +38,12 @@ final class FakeController extends StateController FakeController({int? initialState}) : super(initialState: initialState ?? 0); void add(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state + value); - }); + await Future.delayed(Duration.zero); + setState(state + value); + }); void subtract(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state - value); - }); + await Future.delayed(Duration.zero); + setState(state - value); + }); } diff --git a/test/widget/controller_scope_test.dart b/test/widget/controller_scope_test.dart index 685937f..d687e6b 100644 --- a/test/widget/controller_scope_test.dart +++ b/test/widget/controller_scope_test.dart @@ -7,241 +7,226 @@ import 'package:flutter_test/flutter_test.dart'; import '../util/test_util.dart'; void main() => group('ControllerScope', () { - _$valueGroup(); - _$createGroup(); - _$additionalGroup(); - }); + _$valueGroup(); + _$createGroup(); + _$additionalGroup(); +}); void _$valueGroup() => group('ControllerScope.value', () { - test('constructor', () { - expect( - () => ControllerScope(FakeController.new), - returnsNormally, - ); - expect( - ControllerScope(FakeController.new), - isA(), - ); - }); - - testWidgets( - 'inject_and_recive', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - controller.add(1); - await tester.pumpAndSettle(); - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - controller.subtract(2); - await tester.pumpAndSettle(); - expect(controller.state, equals(-1)); - expect(find.text('-1'), findsOneWidget); - controller.dispose(); - }, - ); - }); + test('constructor', () { + expect(() => ControllerScope(FakeController.new), returnsNormally); + expect(ControllerScope(FakeController.new), isA()); + }); + + testWidgets('inject_and_recive', (tester) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + controller.add(1); + await tester.pumpAndSettle(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + controller.subtract(2); + await tester.pumpAndSettle(); + expect(controller.state, equals(-1)); + expect(find.text('-1'), findsOneWidget); + controller.dispose(); + }); +}); void _$createGroup() => group('ControllerScope.create', () { - test('constructor', () { - expect( - () => ControllerScope(FakeController.new), - returnsNormally, - ); - expect( - ControllerScope(FakeController.new), - isA(), - ); - }); - - testWidgets( - 'inject_and_recive', - (tester) async { - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope( - FakeController.new, - child: StateConsumer( - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - final context = - tester.firstElement(find.byType(ControllerScope)); - final controller = ControllerScope.of(context); - expect( - controller, - isA().having((c) => c.state, 'state', equals(0)), - ); - controller.add(1); - await tester.pumpAndSettle(); - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - controller.subtract(2); - await tester.pumpAndSettle(); - expect(controller.state, equals(-1)); - expect(find.text('-1'), findsOneWidget); - }, - ); - }); + test('constructor', () { + expect(() => ControllerScope(FakeController.new), returnsNormally); + expect(ControllerScope(FakeController.new), isA()); + }); + + testWidgets('inject_and_recive', (tester) async { + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope( + FakeController.new, + child: StateConsumer( + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + final context = tester.firstElement( + find.byType(ControllerScope), + ); + final controller = ControllerScope.of(context); + expect( + controller, + isA().having((c) => c.state, 'state', equals(0)), + ); + controller.add(1); + await tester.pumpAndSettle(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + controller.subtract(2); + await tester.pumpAndSettle(); + expect(controller.state, equals(-1)); + expect(find.text('-1'), findsOneWidget); + }); +}); void _$additionalGroup() => group('ControllerScope.additional', () { - testWidgets('controllerOf should return the correct controller', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final context = - tester.firstElement(find.byType(ControllerScope)); - - final foundController = context.controllerOf(); - expect(foundController, equals(controller)); - }); - - testWidgets('maybeOf should return null if no controller is found', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final context = tester.firstElement(find.byType(HeroControllerScope)); - final foundController = - ControllerScope.maybeOf(context); - expect(foundController, isNull); - }); - - testWidgets('maybeOf should return controller if present', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final context = - tester.firstElement(find.byType(ControllerScope)); - - final foundController = - ControllerScope.maybeOf(context, listen: true); - expect(foundController, equals(controller)); - }); - - testWidgets('_notFoundInheritedWidgetOfExactType should throw error', - (tester) async { - await tester - .pumpWidget(TestUtil.appContext(child: const SizedBox.shrink())); - await tester.pumpAndSettle(); - - final context = tester.firstElement(find.byType(HeroControllerScope)); - expect( - () => ControllerScope.of(context), - throwsArgumentError, - ); - }); - - test('updateShouldNotify should return true for different dependencies', - () { - final controller1 = FakeController(); - final controller2 = FakeController(); - final widget1 = ControllerScope.value( - controller1, - child: const SizedBox.shrink(), - ); - final widget2 = ControllerScope.value( - controller2, - child: const SizedBox.shrink(), - ); - - expect(widget1.updateShouldNotify(widget2), isTrue); - }); - - test('debugFillProperties should correctly fill debug information', () { - final controller = FakeController(); - final widget = ControllerScope.value( + testWidgets('controllerOf should return the correct controller', ( + tester, + ) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( controller, - child: const SizedBox.shrink(), - ); - final element = widget.createElement(); - final properties = DiagnosticPropertiesBuilder(); - - element.debugFillProperties(properties); - - expect(properties.properties, isNotEmpty); - }); - - test('_initController should initialize correctly', () { - final controller = FakeController(); - final widget = ControllerScope.value( + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement( + find.byType(ControllerScope), + ); + + final foundController = context.controllerOf(); + expect(foundController, equals(controller)); + }); + + testWidgets('maybeOf should return null if no controller is found', ( + tester, + ) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( controller, - child: const SizedBox.shrink(), - ); - final element = - widget.createElement() as ControllerScope$Element; - - final initializedController = element.controller; - expect(initializedController, equals(controller)); - }); - - test('_initController should throw error on reinitialization', () { - final controller = FakeController(); - final widget = ControllerScope.value( + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement(find.byType(HeroControllerScope)); + final foundController = ControllerScope.maybeOf(context); + expect(foundController, isNull); + }); + + testWidgets('maybeOf should return controller if present', (tester) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( controller, - child: const SizedBox.shrink(), - ); - final element = - widget.createElement() as ControllerScope$Element; - - // Initialize first time - final initializedController = element.controller; - expect(initializedController, equals(controller)); - - // Trying to reinitialize should cause an error - // expect(element.controller., throwsAssertionError); - }); - }); + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement( + find.byType(ControllerScope), + ); + + final foundController = ControllerScope.maybeOf( + context, + listen: true, + ); + expect(foundController, equals(controller)); + }); + + testWidgets('_notFoundInheritedWidgetOfExactType should throw error', ( + tester, + ) async { + await tester.pumpWidget( + TestUtil.appContext(child: const SizedBox.shrink()), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement(find.byType(HeroControllerScope)); + expect(() => ControllerScope.of(context), throwsArgumentError); + }); + + test('updateShouldNotify should return true for different dependencies', () { + final controller1 = FakeController(); + final controller2 = FakeController(); + final widget1 = ControllerScope.value( + controller1, + child: const SizedBox.shrink(), + ); + final widget2 = ControllerScope.value( + controller2, + child: const SizedBox.shrink(), + ); + + expect(widget1.updateShouldNotify(widget2), isTrue); + }); + + test('debugFillProperties should correctly fill debug information', () { + final controller = FakeController(); + final widget = ControllerScope.value( + controller, + child: const SizedBox.shrink(), + ); + final element = widget.createElement(); + final properties = DiagnosticPropertiesBuilder(); + + element.debugFillProperties(properties); + + expect(properties.properties, isNotEmpty); + }); + + test('_initController should initialize correctly', () { + final controller = FakeController(); + final widget = ControllerScope.value( + controller, + child: const SizedBox.shrink(), + ); + final element = + widget.createElement() as ControllerScope$Element; + + final initializedController = element.controller; + expect(initializedController, equals(controller)); + }); + + test('_initController should throw error on reinitialization', () { + final controller = FakeController(); + final widget = ControllerScope.value( + controller, + child: const SizedBox.shrink(), + ); + final element = + widget.createElement() as ControllerScope$Element; + + // Initialize first time + final initializedController = element.controller; + expect(initializedController, equals(controller)); + + // Trying to reinitialize should cause an error + // expect(element.controller., throwsAssertionError); + }); +}); diff --git a/test/widget/state_consumer_test.dart b/test/widget/state_consumer_test.dart index 68880b5..67d1d31 100644 --- a/test/widget/state_consumer_test.dart +++ b/test/widget/state_consumer_test.dart @@ -6,310 +6,299 @@ import 'package:flutter_test/flutter_test.dart'; import '../util/test_util.dart'; void main() => group('StateConsumer - ', () { - _$baseGroup(); - _$didUpdateWidgetGroup(); - _$debugFillPropertiesGroup(); - }); + _$baseGroup(); + _$didUpdateWidgetGroup(); + _$debugFillPropertiesGroup(); +}); void _$baseGroup() => group('base - ', () { - testWidgets('should update controller when widget controller changes', - (tester) async { - final controller1 = FakeController(); - final controller2 = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller1, - child: StateConsumer( - controller: controller1, - builder: (context, state, child) => Text('$state'), - ), - ), + testWidgets('should update controller when widget controller changes', ( + tester, + ) async { + final controller1 = FakeController(); + final controller2 = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller1, + child: StateConsumer( + controller: controller1, + builder: (context, state, child) => Text('$state'), ), - ); - - controller1.add(1); - await tester.pumpAndSettle(); - - expect(find.text('1'), findsOneWidget); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller2, - child: StateConsumer( - controller: controller2, - builder: (context, state, child) => Text('$state'), - ), - ), + ), + ), + ); + + controller1.add(1); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsOneWidget); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller2, + child: StateConsumer( + controller: controller2, + builder: (context, state, child) => Text('$state'), ), - ); + ), + ), + ); - expect(find.text('0'), findsOneWidget); + expect(find.text('0'), findsOneWidget); - controller2.add(2); - await tester.pumpAndSettle(); + controller2.add(2); + await tester.pumpAndSettle(); - expect(find.text('2'), findsOneWidget); + expect(find.text('2'), findsOneWidget); - controller1.add(1); - await tester.pumpAndSettle(); + controller1.add(1); + await tester.pumpAndSettle(); - expect(find.text('3'), findsNothing); - expect(find.text('2'), findsOneWidget); - }); + expect(find.text('3'), findsNothing); + expect(find.text('2'), findsOneWidget); + }); - testWidgets( - 'should not rebuild when states are identical in _valueChanged', - (tester) async { - final controller = FakeController(); + testWidgets('should not rebuild when states are identical in _valueChanged', ( + tester, + ) async { + final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), ), - ); + ), + ), + ); - expect(find.text('0'), findsOneWidget); + expect(find.text('0'), findsOneWidget); - controller.add(1); + controller.add(1); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); - expect(find.text('1'), findsOneWidget); - }); + expect(find.text('1'), findsOneWidget); + }); - testWidgets('should not rebuild when buildWhen returns false', - (tester) async { - final controller = FakeController(); + testWidgets('should not rebuild when buildWhen returns false', ( + tester, + ) async { + final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - buildWhen: (previous, current) => false, // No rebuild - builder: (context, state, child) => Text('$state'), - ), - ), + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + buildWhen: (previous, current) => false, // No rebuild + builder: (context, state, child) => Text('$state'), ), - ); - - expect(find.text('0'), findsOneWidget); - - controller.add(1); - - await tester.pumpAndSettle(); // Rebuild should not happen - - expect(find.text('1'), findsNothing); // Should still show 0 - expect(find.text('0'), findsOneWidget); - }); - - testWidgets('should use child if builder is not provided', - (tester) async { - const childWidget = Text('Child Widget'); - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - child: childWidget, - ), - ), + ), + ), + ); + + expect(find.text('0'), findsOneWidget); + + controller.add(1); + + await tester.pumpAndSettle(); // Rebuild should not happen + + expect(find.text('1'), findsNothing); // Should still show 0 + expect(find.text('0'), findsOneWidget); + }); + + testWidgets('should use child if builder is not provided', (tester) async { + const childWidget = Text('Child Widget'); + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer(controller: controller, child: childWidget), + ), + ), + ); + + expect(find.text('Child Widget'), findsOneWidget); + }); + + testWidgets('should rebuild with widget child ' + 'if both builder and child are provided', (tester) async { + const childWidget = Text('Child Widget'); + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => + Column(children: [Text('State: $state'), child ?? childWidget]), + child: childWidget, ), - ); - - expect(find.text('Child Widget'), findsOneWidget); - }); - - testWidgets( - 'should rebuild with widget child ' - 'if both builder and child are provided', (tester) async { - const childWidget = Text('Child Widget'); - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Column( - children: [ - Text('State: $state'), - child ?? childWidget, - ], - ), - child: childWidget, - ), - ), - ), - ); + ), + ), + ); - // Initial state - await tester.pumpAndSettle(); - expect(find.text('Child Widget'), findsOneWidget); - expect(find.text('State: 0'), findsOneWidget); + // Initial state + await tester.pumpAndSettle(); + expect(find.text('Child Widget'), findsOneWidget); + expect(find.text('State: 0'), findsOneWidget); - // Update state - controller.add(1); - await tester.pumpAndSettle(); + // Update state + controller.add(1); + await tester.pumpAndSettle(); - expect(find.text('Child Widget'), findsOneWidget); - expect(find.text('State: 1'), findsOneWidget); - }); - }); + expect(find.text('Child Widget'), findsOneWidget); + expect(find.text('State: 1'), findsOneWidget); + }); +}); void _$debugFillPropertiesGroup() => group('debugFillProperties - ', () { - testWidgets('should fill full debug properties correctly', - (tester) async { - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), + testWidgets('should fill full debug properties correctly', (tester) async { + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), ), - ); - - final context = - tester.firstElement(find.byType(ControllerScope)); - - // Filling in debugging properties - final properties = DiagnosticPropertiesBuilder(); - context.debugFillProperties(properties); - - // Check all expected properties are filled - final propertyNames = properties.properties.map((p) => p.name).toList(); - expect( - propertyNames, - containsAll([ - 'StateController', - 'State', - 'Subscribers', - 'isDisposed', - 'isProcessing', - 'depth', - 'widget', - 'key', - 'dirty', - ]), - ); - }); - - testWidgets('should fill debug properties correctly', (tester) async { - final stateConsumerKey = GlobalKey>(); - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - key: stateConsumerKey, - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), + ), + ), + ); + + final context = tester.firstElement( + find.byType(ControllerScope), + ); + + // Filling in debugging properties + final properties = DiagnosticPropertiesBuilder(); + context.debugFillProperties(properties); + + // Check all expected properties are filled + final propertyNames = properties.properties.map((p) => p.name).toList(); + expect( + propertyNames, + containsAll([ + 'StateController', + 'State', + 'Subscribers', + 'isDisposed', + 'isProcessing', + 'depth', + 'widget', + 'key', + 'dirty', + ]), + ); + }); + + testWidgets('should fill debug properties correctly', (tester) async { + final stateConsumerKey = GlobalKey>(); + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + key: stateConsumerKey, + controller: controller, + builder: (context, state, child) => Text('$state'), ), - ); + ), + ), + ); - // Filling in debugging properties - final properties = DiagnosticPropertiesBuilder(); - stateConsumerKey.currentState?.debugFillProperties(properties); + // Filling in debugging properties + final properties = DiagnosticPropertiesBuilder(); + stateConsumerKey.currentState?.debugFillProperties(properties); - // Check all expected properties are filled - final propertyNames = properties.properties.map((p) => p.name).toList(); - expect( - propertyNames, - containsAll(['Controller', 'State', 'isProcessing']), - ); - }); - }); + // Check all expected properties are filled + final propertyNames = properties.properties.map((p) => p.name).toList(); + expect(propertyNames, containsAll(['Controller', 'State', 'isProcessing'])); + }); +}); void _$didUpdateWidgetGroup() => group('didUpdateWidget - ', () { - testWidgets( - 'should use controller from ControllerScope ' - 'when newController is null', (tester) async { - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - // В первый раз указываем начальный контроллер - controller: controller, - builder: (context, state, child) => Text('State: $state'), - ), - ), + testWidgets('should use controller from ControllerScope ' + 'when newController is null', (tester) async { + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + // В первый раз указываем начальный контроллер + controller: controller, + builder: (context, state, child) => Text('State: $state'), ), - ); - - // Change the current controller's state to check - // that the widget is updating correctly - controller.add(1); - await tester.pumpAndSettle(); - expect(find.text('State: 1'), findsOneWidget); - - // Rebuild the widget without a controller, - // check that the controller from ControllerScope is used - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - // Здесь передаем null контроллер - controller: null, - builder: (context, state, child) => Text('State: $state'), - ), - ), + ), + ), + ); + + // Change the current controller's state to check + // that the widget is updating correctly + controller.add(1); + await tester.pumpAndSettle(); + expect(find.text('State: 1'), findsOneWidget); + + // Rebuild the widget without a controller, + // check that the controller from ControllerScope is used + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + // Здесь передаем null контроллер + controller: null, + builder: (context, state, child) => Text('State: $state'), ), - ); - - // Check that the controller state is taken from ControllerScope - // and is displayed correctly - expect(find.text('State: 1'), findsOneWidget); - - // Change the state of the controller in ControllerScope - // and check that the widget is updated correctly - controller.add(2); - await tester.pumpAndSettle(); - expect(find.text('State: 3'), findsOneWidget); // Было 1, добавили 2 - - // Additionally, we check that a new controller is not created - // and is used only from Scope - final newController = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: newController, - builder: (context, state, child) => Text('State: $state'), - ), - ), + ), + ), + ); + + // Check that the controller state is taken from ControllerScope + // and is displayed correctly + expect(find.text('State: 1'), findsOneWidget); + + // Change the state of the controller in ControllerScope + // and check that the widget is updated correctly + controller.add(2); + await tester.pumpAndSettle(); + expect(find.text('State: 3'), findsOneWidget); // Было 1, добавили 2 + + // Additionally, we check that a new controller is not created + // and is used only from Scope + final newController = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: newController, + builder: (context, state, child) => Text('State: $state'), ), - ); - - // The new controller has an initial value of 0 - // and the widget will update to show this. - expect(find.text('State: 0'), findsOneWidget); - }); - }); + ), + ), + ); + + // The new controller has an initial value of 0 + // and the widget will update to show this. + expect(find.text('State: 0'), findsOneWidget); + }); +});