From e3f09d41e08583b594806d2d6b61de15765391eb Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 15:22:04 +0400 Subject: [PATCH 01/12] Add context --- lib/control.dart | 7 ++- lib/src/concurrency/concurrency.dart | 3 ++ .../concurrent_controller_handler.dart | 16 ++++++- .../droppable_controller_handler.dart | 14 ++++++ .../sequential_controller_handler.dart | 14 ++++++ lib/src/controller.dart | 23 ++++++++-- lib/src/handler_context.dart | 43 +++++++++++++++++++ lib/src/state_controller.dart | 5 +++ pubspec.yaml | 6 +-- 9 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 lib/src/concurrency/concurrency.dart rename lib/src/{ => concurrency}/concurrent_controller_handler.dart (86%) rename lib/src/{ => concurrency}/droppable_controller_handler.dart (82%) rename lib/src/{ => concurrency}/sequential_controller_handler.dart (89%) create mode 100644 lib/src/handler_context.dart diff --git a/lib/control.dart b/lib/control.dart index 1541dc1..c07da81 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -1,9 +1,8 @@ -library control; +library; -export 'package:control/src/concurrent_controller_handler.dart'; +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/droppable_controller_handler.dart'; -export 'package:control/src/sequential_controller_handler.dart'; +export 'package:control/src/handler_context.dart' show HandlerContext; export 'package:control/src/state_consumer.dart'; export 'package:control/src/state_controller.dart' hide IStateController; diff --git a/lib/src/concurrency/concurrency.dart b/lib/src/concurrency/concurrency.dart new file mode 100644 index 0000000..251dc8f --- /dev/null +++ b/lib/src/concurrency/concurrency.dart @@ -0,0 +1,3 @@ +export 'package:control/src/concurrency/concurrent_controller_handler.dart'; +export 'package:control/src/concurrency/droppable_controller_handler.dart'; +export 'package:control/src/concurrency/sequential_controller_handler.dart'; diff --git a/lib/src/concurrent_controller_handler.dart b/lib/src/concurrency/concurrent_controller_handler.dart similarity index 86% rename from lib/src/concurrent_controller_handler.dart rename to lib/src/concurrency/concurrent_controller_handler.dart index c5864ba..21c3026 100644 --- a/lib/src/concurrent_controller_handler.dart +++ b/lib/src/concurrency/concurrent_controller_handler.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'package:control/src/controller.dart'; +import 'package:control/src/handler_context.dart'; import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:meta/meta.dart'; -/// Sequential controller concurrency +/// Concurrent controller concurrency base mixin ConcurrentControllerHandler on Controller { @override @nonVirtual @@ -22,6 +23,8 @@ base mixin ConcurrentControllerHandler on Controller { Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, + String? name, + Map? context, }) { if (isDisposed) return Future.value(null); _$processingCalls++; @@ -46,6 +49,14 @@ base mixin ConcurrentControllerHandler on Controller { _done = null; } + final handlerContext = HandlerContextImpl( + controller: this, + name: name ?? '$runtimeType.handler#${handler.runtimeType}', + context: { + ...?context, + }, + ); + runZonedGuarded( () async { try { @@ -63,6 +74,9 @@ base mixin ConcurrentControllerHandler on Controller { } }, onError, + zoneValues: { + HandlerContext.key: handlerContext, + }, ); return completer.future; diff --git a/lib/src/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart similarity index 82% rename from lib/src/droppable_controller_handler.dart rename to lib/src/concurrency/droppable_controller_handler.dart index 42bee35..e4318b1 100644 --- a/lib/src/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:control/src/controller.dart'; +import 'package:control/src/handler_context.dart'; import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:meta/meta.dart'; @@ -22,6 +23,8 @@ base mixin DroppableControllerHandler on Controller { Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, + String? name, + Map? context, }) { if (isDisposed || isProcessing) return Future.value(null); _$processingCalls++; @@ -46,6 +49,14 @@ base mixin DroppableControllerHandler on Controller { _done = null; } + final handlerContext = HandlerContextImpl( + controller: this, + name: name ?? '$runtimeType.handler#${handler.runtimeType}', + context: { + ...?context, + }, + ); + runZonedGuarded( () async { try { @@ -63,6 +74,9 @@ base mixin DroppableControllerHandler on Controller { } }, onError, + zoneValues: { + HandlerContext.key: handlerContext, + }, ); return completer.future; diff --git a/lib/src/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart similarity index 89% rename from lib/src/sequential_controller_handler.dart rename to lib/src/concurrency/sequential_controller_handler.dart index 271cfd4..eb9896c 100644 --- a/lib/src/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:control/src/controller.dart'; +import 'package:control/src/handler_context.dart'; import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:meta/meta.dart'; @@ -24,6 +25,8 @@ base mixin SequentialControllerHandler on Controller { Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, + String? name, + Map? context, }) => _eventQueue.push( () { @@ -40,6 +43,14 @@ base mixin SequentialControllerHandler on Controller { } } + final handlerContext = HandlerContextImpl( + controller: this, + name: name ?? '$runtimeType.handler#${handler.runtimeType}', + context: { + ...?context, + }, + ); + runZonedGuarded( () async { if (isDisposed) return; @@ -58,6 +69,9 @@ base mixin SequentialControllerHandler on Controller { } }, onError, + zoneValues: { + HandlerContext.key: handlerContext, + }, ); return completer.future; diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 1b077e7..e403c30 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -1,7 +1,8 @@ import 'dart:async'; -import 'package:control/control.dart'; +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; import 'package:meta/meta.dart'; @@ -37,11 +38,19 @@ abstract interface class IController implements Listenable { /// Depending on the implementation, the handler may be executed /// sequentially, concurrently, dropped and etc. /// + /// The [name] parameter is used to identify the handler. + /// The [context] parameter is used to pass additional + /// information to the handler's zone. + /// /// See: /// - [ConcurrentControllerHandler] - handler that executes concurrently /// - [SequentialControllerHandler] - handler that executes sequentially /// - [DroppableControllerHandler] - handler that drops the request when busy - void handle(Future Function() handler); + void handle( + Future Function() handler, { + String? name, + Map? context, + }); } /// Controller observer @@ -74,6 +83,10 @@ abstract base class Controller with ChangeNotifier implements IController { ); } + /// Get the handler's context from the current zone. + static HandlerContext? getContext(Controller controller) => + HandlerContext.zoned(); + /// Controller observer static IControllerObserver? observer; @@ -101,7 +114,11 @@ abstract base class Controller with ChangeNotifier implements IController { @protected @override - Future handle(Future Function() handler); + Future handle( + Future Function() handler, { + String? name, + Map? context, + }); @protected @nonVirtual diff --git a/lib/src/handler_context.dart b/lib/src/handler_context.dart new file mode 100644 index 0000000..4bd1ac6 --- /dev/null +++ b/lib/src/handler_context.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:control/src/controller.dart'; +import 'package:meta/meta.dart'; + +/// Handler's context. +abstract interface class HandlerContext { + /// Key to access the handler's context. + static const Object key = #handler; + + /// Get the handler's context from the current zone. + static HandlerContext? zoned() => switch (Zone.current[HandlerContext.key]) { + HandlerContext context => context, + _ => null, + }; + + /// Controller that the handler is attached to. + abstract final Controller controller; + + /// Name of the handler. + abstract final String name; + + /// Extra meta information about the handler. + abstract final Map context; +} + +@internal +final class HandlerContextImpl implements HandlerContext { + HandlerContextImpl({ + required this.controller, + required this.name, + required this.context, + }); + + @override + final Controller controller; + + @override + final String name; + + @override + final Map context; +} diff --git a/lib/src/state_controller.dart b/lib/src/state_controller.dart index 3b3b08d..147bbae 100644 --- a/lib/src/state_controller.dart +++ b/lib/src/state_controller.dart @@ -1,6 +1,7 @@ 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'; @@ -28,6 +29,10 @@ abstract base class StateController extends Controller /// State controller StateController({required S initialState}) : _$state = initialState; + /// Get the handler's context from the current zone. + static HandlerContext? getContext(Controller controller) => + HandlerContext.zoned(); + @override @nonVirtual S get state => _$state; diff --git a/pubspec.yaml b/pubspec.yaml index 9af1540..0c88b47 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: control description: "Simple state management for Flutter with concurrency support." -version: 0.1.0 +version: 0.2.0 homepage: https://github.com/PlugFox/control @@ -34,7 +34,7 @@ platforms: # path: example.png environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.4.0 <4.0.0' flutter: ">=3.0.0" dependencies: @@ -45,4 +45,4 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^2.0.0 + flutter_lints: ^5.0.0 From 0c613f46fc23df9e8eb83c4bf5247d166ef2cfe2 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 15:26:08 +0400 Subject: [PATCH 02/12] Ignore generated files and update .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index cf99b8b..1fcaad7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ coverage/ /temp # FVM -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +# Generated files +*.*.dart \ No newline at end of file From 4dbfd0b203b0ddbb2851cecae90f6570f9ba8800 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 15:36:47 +0400 Subject: [PATCH 03/12] Update CI --- .github/setup/action.yaml | 65 ++++++++++++++++++++++++++++++ .github/workflows/checkout.yml | 67 ++++++++++++++++--------------- .github/workflows/test-report.yml | 27 +++++++++++++ 3 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 .github/setup/action.yaml create mode 100644 .github/workflows/test-report.yml diff --git a/.github/setup/action.yaml b/.github/setup/action.yaml new file mode 100644 index 0000000..e9b5728 --- /dev/null +++ b/.github/setup/action.yaml @@ -0,0 +1,65 @@ +name: Setup +description: Sets up the Flutter environment + +inputs: + flutter-version: + description: 'The version of Flutter to use' + required: false + default: '3.24.3' + pub-cache: + description: 'The name of the pub cache variable' + required: false + default: control + +runs: + using: composite + steps: + - name: ๐Ÿ“ฆ Checkout the repo + uses: actions/checkout@v4 + + - name: ๐Ÿ”ข Set up version from tags + id: set-version + if: startsWith(github.ref, 'refs/tags') + shell: bash + run: | + BASE_VERSION="${GITHUB_REF#refs/tags/v}" + UNIXTIME=$(date +%s) + VERSION="${BASE_VERSION}+${UNIXTIME}" + echo "VERSION=$VERSION" >> $GITHUB_ENV + sed -i "s/^version: .*/version: ${VERSION}/" pubspec.yaml + echo "Version set to $VERSION" + + - name: ๐Ÿš‚ Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '${{ inputs.flutter-version }}' + channel: "stable" + + - name: ๐Ÿ“ค Restore Pub modules + id: cache-pub-restore + uses: actions/cache/restore@v4 + with: + path: | + /home/runner/.pub-cache + key: ${{ runner.os }}-pub-${{ inputs.pub-cache }}-${{ hashFiles('pubspec.lock') }} + + - name: ๐Ÿ‘ท Install Dependencies + shell: bash + run: | + echo /home/runner/.pub-cache/bin >> $GITHUB_PATH + flutter config --no-cli-animations --no-analytics + flutter pub get + + #- name: โฒ๏ธ Run build runner + # shell: bash + # run: | + # dart run build_runner build --delete-conflicting-outputs --release + + - name: ๐Ÿ“ฅ Save Pub modules + id: cache-pub-save + if: steps.cache-pub-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + /home/runner/.pub-cache + key: ${{ steps.cache-pub-restore.outputs.cache-primary-key }} diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 35d248b..77ad758 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -5,16 +5,6 @@ on: push: branches: - "master" - - "develop" - - "feature/**" - - "bugfix/**" - - "hotfix/**" - - "support/**" - paths: - - "lib/**.dart" - - "test/**.dart" - - "example/**.dart" - - "pubspec.yaml" pull_request: branches: - "master" @@ -24,54 +14,58 @@ on: - "hotfix/**" - "support/**" paths: + - "pubspec.yaml" + - "pubspec.lock" - "lib/**.dart" - "test/**.dart" - "example/**.dart" - - "pubspec.yaml" + +permissions: + contents: read + actions: read + checks: write jobs: checkout: - name: "Checkout" + name: "๐Ÿงช Check code with analysis, format, and tests" runs-on: ubuntu-latest + timeout-minutes: 10 defaults: run: working-directory: ./ - container: - image: plugfox/flutter:stable - timeout-minutes: 10 steps: - - name: ๐Ÿš‚ Get latest code - uses: actions/checkout@v3 - - - name: ๐Ÿšƒ Cache pub modules - uses: actions/cache@v2 - env: - cache-name: cache-octopus-package + - name: ๐Ÿ“ฆ Get the .github actions + uses: actions/checkout@v4 with: - path: | - $PWD/.pub_cache/ - key: ${{ runner.os }}-pub-${{ env.cache-name }}-${{ hashFiles('**/pubspec.yaml') }} + sparse-checkout: | + .github - - name: ๐Ÿ—„๏ธ Export pub cache directory - run: export PUB_CACHE=$PWD/.pub_cache/ + - name: ๐Ÿš‚ Setup Flutter and dependencies + uses: ./.github/actions/setup + with: + flutter-version: 3.24.3 - name: ๐Ÿ‘ท Install Dependencies timeout-minutes: 1 run: | flutter pub get - - name: ๐Ÿ”Ž Check format + - name: ๐Ÿšฆ Check code format + id: check-format timeout-minutes: 1 - run: dart format --set-exit-if-changed -l 80 -o none lib/ + run: | + find lib test -name "*.dart" ! -name "*.*.dart" -print0 | xargs -0 dart format --set-exit-if-changed --line-length 80 -o none - - name: ๐Ÿ“ˆ Check analyzer + - name: ๐Ÿ“ˆ Check for Warnings + id: check-analyzer timeout-minutes: 1 - run: flutter analyze --fatal-infos --fatal-warnings lib/ + run: | + flutter analyze --fatal-infos --fatal-warnings lib/ test/ - - name: ๐Ÿงช Run tests + - name: ๐Ÿงช Unit & Widget tests timeout-minutes: 2 run: | - flutter test -r github -j 6 --coverage test/control_test.dart + flutter test -r github --concurrency=6 --coverage test/control_test.dart - name: ๐Ÿ“ฅ Upload coverage to Codecov timeout-minutes: 1 @@ -79,3 +73,10 @@ jobs: with: files: ./coverage/lcov.info # token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + + - name: ๐Ÿ“ฅ Upload test report + uses: actions/upload-artifact@v4 + if: (success() || failure()) && ${{ github.actor != 'dependabot[bot]' }} + with: + name: test-results + path: reports/tests.json diff --git a/.github/workflows/test-report.yml b/.github/workflows/test-report.yml new file mode 100644 index 0000000..6fe454d --- /dev/null +++ b/.github/workflows/test-report.yml @@ -0,0 +1,27 @@ +name: "Test Report" + +on: + workflow_run: + workflows: ["Checkout"] # runs after "Checkout" workflow + types: + - completed + +permissions: + contents: read + actions: read + checks: write + +jobs: + report: + name: "๐Ÿš› Test report" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Test report + uses: dorny/test-reporter@v1 + with: + artifact: test-results + name: Test Report + path: "**/tests.json" + reporter: flutter-json + fail-on-error: false From 81520ccc085eb5690eb15b883f2f5da731f26874 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 16:00:57 +0400 Subject: [PATCH 04/12] Update tests --- .../concurrent_controller_handler.dart | 36 ++++++-- .../droppable_controller_handler.dart | 31 +++++-- .../sequential_controller_handler.dart | 29 +++++-- lib/src/controller.dart | 8 +- lib/src/handler_context.dart | 25 ++++-- test/unit/state_controller_test.dart | 85 ++++++++++--------- 6 files changed, 144 insertions(+), 70 deletions(-) diff --git a/lib/src/concurrency/concurrent_controller_handler.dart b/lib/src/concurrency/concurrent_controller_handler.dart index 21c3026..9acebad 100644 --- a/lib/src/concurrency/concurrent_controller_handler.dart +++ b/lib/src/concurrency/concurrent_controller_handler.dart @@ -2,20 +2,25 @@ import 'dart:async'; import 'package:control/src/controller.dart'; import 'package:control/src/handler_context.dart'; -import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:meta/meta.dart'; -/// Concurrent controller concurrency +/// 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; - int _$processingCalls = 0; - @override - Future get done => _done?.future ?? SynchronousFuture(null); - Completer? _done; + /// 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. + /// [context] is an optional HashMap of context data to be passed to the zone. @override @protected @mustCallSuper @@ -28,10 +33,11 @@ base mixin ConcurrentControllerHandler on Controller { }) { if (isDisposed) return Future.value(null); _$processingCalls++; - final completer = _done ??= Completer(); + 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; @@ -41,17 +47,29 @@ base mixin ConcurrentControllerHandler on Controller { } } + 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--; if (_$processingCalls != 0) return; completer.complete(); - _done = null; } final handlerContext = HandlerContextImpl( controller: this, name: name ?? '$runtimeType.handler#${handler.runtimeType}', + completer: completer, context: { ...?context, }, @@ -73,7 +91,7 @@ base mixin ConcurrentControllerHandler on Controller { onDone(); } }, - onError, + handleZoneError, zoneValues: { HandlerContext.key: handlerContext, }, diff --git a/lib/src/concurrency/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart index e4318b1..034f1ab 100644 --- a/lib/src/concurrency/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:control/src/controller.dart'; import 'package:control/src/handler_context.dart'; -import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:meta/meta.dart'; /// Droppable controller concurrency @@ -12,10 +11,13 @@ base mixin DroppableControllerHandler on Controller { bool get isProcessing => _$processingCalls > 0; int _$processingCalls = 0; - @override - Future get done => _done?.future ?? SynchronousFuture(null); - Completer? _done; - + /// 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. + /// [context] is an optional HashMap of context data to be passed to the zone. @override @protected @mustCallSuper @@ -28,10 +30,11 @@ base mixin DroppableControllerHandler on Controller { }) { if (isDisposed || isProcessing) return Future.value(null); _$processingCalls++; - final completer = _done ??= Completer(); + 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; @@ -41,17 +44,29 @@ base mixin DroppableControllerHandler on Controller { } } + 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--; if (_$processingCalls != 0) return; completer.complete(); - _done = null; } final handlerContext = HandlerContextImpl( controller: this, name: name ?? '$runtimeType.handler#${handler.runtimeType}', + completer: completer, context: { ...?context, }, @@ -73,7 +88,7 @@ base mixin DroppableControllerHandler on Controller { onDone(); } }, - onError, + handleZoneError, zoneValues: { HandlerContext.key: handlerContext, }, diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index eb9896c..23c9be1 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -3,7 +3,6 @@ import 'dart:collection'; import 'package:control/src/controller.dart'; import 'package:control/src/handler_context.dart'; -import 'package:flutter/foundation.dart' show SynchronousFuture; import 'package:meta/meta.dart'; /// Sequential controller concurrency @@ -14,10 +13,13 @@ base mixin SequentialControllerHandler on Controller { @nonVirtual bool get isProcessing => _eventQueue.length > 0; - @override - Future get done => - _eventQueue._processing ?? SynchronousFuture(null); - + /// 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. + /// [context] is an optional HashMap of context data to be passed to the zone. @override @protected @mustCallSuper @@ -34,6 +36,7 @@ base mixin SequentialControllerHandler on Controller { 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; @@ -43,9 +46,23 @@ base mixin SequentialControllerHandler on Controller { } } + 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 ?? '$runtimeType.handler#${handler.runtimeType}', + completer: completer, context: { ...?context, }, @@ -68,7 +85,7 @@ base mixin SequentialControllerHandler on Controller { if (!completer.isCompleted) completer.complete(); } }, - onError, + handleZoneError, zoneValues: { HandlerContext.key: handlerContext, }, diff --git a/lib/src/controller.dart b/lib/src/controller.dart index e403c30..6a83112 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -25,9 +25,6 @@ abstract interface class IController implements Listenable { /// Whether the controller is currently handling a requests bool get isProcessing; - /// A future that completes when the controller is done processing. - Future get done; - /// Discards any resources used by the object. /// /// This method should only be called by the object's owner. @@ -112,6 +109,11 @@ abstract base class Controller with ChangeNotifier implements IController { (error, stackTrace) {/* ignore */}, // coverage:ignore-line ); + /// Handles a given operation with error handling and completion tracking. + /// + /// [handler] is the main operation to be executed. + /// [name] is an optional name for the operation, used for debugging. + /// [context] is an optional HashMap of context data to be passed to the zone. @protected @override Future handle( diff --git a/lib/src/handler_context.dart b/lib/src/handler_context.dart index 4bd1ac6..61712f4 100644 --- a/lib/src/handler_context.dart +++ b/lib/src/handler_context.dart @@ -20,17 +20,24 @@ abstract interface class HandlerContext { /// Name of the handler. abstract final String name; + /// Future that completes when the handler is done. + Future get done; + + /// Whether the handler is done. + bool get isDone; + /// Extra meta information about the handler. abstract final Map context; } @internal final class HandlerContextImpl implements HandlerContext { - HandlerContextImpl({ - required this.controller, - required this.name, - required this.context, - }); + HandlerContextImpl( + {required this.controller, + required this.name, + required this.context, + required Completer completer}) + : _completer = completer; @override final Controller controller; @@ -38,6 +45,14 @@ final class HandlerContextImpl implements HandlerContext { @override final String name; + @override + Future get done => _completer.future; + + @override + bool get isDone => _completer.isCompleted; + + final Completer _completer; + @override final Map context; } diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 956ee09..1bdbb16 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -21,12 +21,13 @@ void _$concurrencyGroup() => group('concurrency', () { expect(controller.state, equals(0)); expect(controller.subscribers, equals(0)); expect(controller.isDisposed, isFalse); - controller - ..add(1) - ..subtract(2) - ..add(4); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); expect(controller.isProcessing, isTrue); - await expectLater(controller.done, completes); + await expectLater(done, completes); expect(controller.isProcessing, isFalse); expect(controller.state, equals(3)); expect(controller.subscribers, equals(0)); @@ -46,12 +47,13 @@ void _$concurrencyGroup() => group('concurrency', () { expect(controller.state, equals(0)); expect(controller.subscribers, equals(0)); expect(controller.isDisposed, isFalse); - controller - ..add(1) - ..subtract(2) - ..add(4); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); expect(controller.isProcessing, isTrue); - await expectLater(controller.done, completes); + await expectLater(done, completes); expect(controller.isProcessing, isFalse); expect(controller.state, equals(1)); expect(controller.subscribers, equals(0)); @@ -71,12 +73,13 @@ void _$concurrencyGroup() => group('concurrency', () { expect(controller.state, equals(0)); expect(controller.subscribers, equals(0)); expect(controller.isDisposed, isFalse); - controller - ..add(1) - ..subtract(2) - ..add(4); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); expect(controller.isProcessing, isTrue); - await expectLater(controller.done, completes); + await expectLater(done, completes); expect(controller.isProcessing, isFalse); expect(controller.state, equals(3)); expect(controller.subscribers, equals(0)); @@ -99,17 +102,16 @@ void _$exceptionalGroup() => group('exceptional', () { test('handles edge case of adding large values', () async { const largeValue = 9223372036854775807; - final controller = _FakeControllerConcurrent()..add(largeValue); - await expectLater(controller.done, completes); + 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() - ..subtract(largeNegativeValue); - await expectLater(controller.done, completes); + final controller = _FakeControllerConcurrent(); + await expectLater(controller.subtract(largeNegativeValue), completes); expect(controller.state, equals(-largeNegativeValue)); controller.dispose(); }); @@ -118,10 +120,9 @@ void _$exceptionalGroup() => group('exceptional', () { final stopwatch = Stopwatch()..start(); try { final controller = _FakeControllerConcurrent(); - for (var i = 0; i < 1000; i++) { - controller.add(1); - } - await expectLater(controller.done, completes); + 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 { @@ -202,11 +203,11 @@ void _$methodsGroup() => group('methods', () { var listenerCalled = 0; mergedListenable.addListener(() => listenerCalled++); - controllerOne.add(1); + controllerOne.add(1).ignore(); await Future.delayed(Duration.zero); expect(listenerCalled, equals(1)); - controllerTwo.add(1); + controllerTwo.add(1).ignore(); await Future.delayed(Duration.zero); expect(listenerCalled, equals(2)); }); @@ -219,12 +220,15 @@ void _$methodsGroup() => group('methods', () { controller.toStream(), emitsInOrder([1, 0, -1, 2, emitsDone]), ); - controller - ..add(1) - ..subtract(1) - ..subtract(1) - ..add(3); - await expectLater(controller.done, completes); + await expectLater( + Future.wait([ + controller.add(1), + controller.subtract(1), + controller.subtract(1), + controller.add(3), + ]), + completes, + ); controller.dispose(); }); @@ -233,14 +237,17 @@ void _$methodsGroup() => group('methods', () { final listenable = controller.toValueListenable(); expect(listenable, isA>()); expect(listenable.value, equals(controller.state)); - controller - ..add(2) - ..subtract(1); - await expectLater(controller.done, completes); + 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); + controller.add(1).ignore(); await expectLater(completer.future, completes); expect(completer.isCompleted, isTrue); controller.dispose(); @@ -416,12 +423,12 @@ abstract base class _FakeControllerBase extends StateController { _FakeControllerBase({int? initialState}) : super(initialState: initialState ?? 0); - void add(int value) => handle(() async { + Future add(int value) => handle(() async { await Future.delayed(Duration.zero); setState(state + value); }); - void subtract(int value) => handle(() async { + Future subtract(int value) => handle(() async { await Future.delayed(Duration.zero); setState(state - value); }); From b211db5819a0300c35be5e715facce3565d79a83 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 16:05:44 +0400 Subject: [PATCH 05/12] Refactor concurrent controller handlers --- lib/src/concurrency/concurrent_controller_handler.dart | 4 ++-- lib/src/concurrency/droppable_controller_handler.dart | 4 ++-- lib/src/concurrency/sequential_controller_handler.dart | 8 +++++++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/src/concurrency/concurrent_controller_handler.dart b/lib/src/concurrency/concurrent_controller_handler.dart index 9acebad..cde6181 100644 --- a/lib/src/concurrency/concurrent_controller_handler.dart +++ b/lib/src/concurrency/concurrent_controller_handler.dart @@ -62,7 +62,6 @@ base mixin ConcurrentControllerHandler on Controller { void onDone() { if (completer.isCompleted) return; _$processingCalls--; - if (_$processingCalls != 0) return; completer.complete(); } @@ -87,8 +86,9 @@ base mixin ConcurrentControllerHandler on Controller { await done?.call(); } on Object catch (error, stackTrace) { super.onError(error, stackTrace); + } finally { + onDone(); } - onDone(); } }, handleZoneError, diff --git a/lib/src/concurrency/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart index 034f1ab..20713f6 100644 --- a/lib/src/concurrency/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -59,7 +59,6 @@ base mixin DroppableControllerHandler on Controller { void onDone() { if (completer.isCompleted) return; _$processingCalls--; - if (_$processingCalls != 0) return; completer.complete(); } @@ -84,8 +83,9 @@ base mixin DroppableControllerHandler on Controller { await done?.call(); } on Object catch (error, stackTrace) { super.onError(error, stackTrace); + } finally { + onDone(); } - onDone(); } }, handleZoneError, diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index 23c9be1..9dc37a4 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -68,6 +68,11 @@ base mixin SequentialControllerHandler on Controller { }, ); + void onDone() { + if (completer.isCompleted) return; + completer.complete(); + } + runZonedGuarded( () async { if (isDisposed) return; @@ -81,8 +86,9 @@ base mixin SequentialControllerHandler on Controller { await done?.call(); } on Object catch (error, stackTrace) { super.onError(error, stackTrace); + } finally { + onDone(); } - if (!completer.isCompleted) completer.complete(); } }, handleZoneError, From 5382ba73e09caf5cd5b4b3611342e3f65445fe30 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 16:56:40 +0400 Subject: [PATCH 06/12] Update dependencies in pubspec.yaml --- example/lib/main.dart | 55 ++++++- example/pubspec.lock | 28 ++-- example/pubspec.yaml | 2 +- .../concurrent_controller_handler.dart | 12 +- .../droppable_controller_handler.dart | 12 +- .../sequential_controller_handler.dart | 13 +- lib/src/controller.dart | 24 ++- lib/src/handler_context.dart | 6 +- lib/src/state_controller.dart | 3 +- test/control_test.dart | 2 + test/unit/handler_context_test.dart | 142 ++++++++++++++++++ 11 files changed, 251 insertions(+), 48 deletions(-) create mode 100644 test/unit/handler_context_test.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 17fcc6c..18c5f62 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,23 +10,68 @@ final class ControllerObserver implements IControllerObserver { @override void onCreate(Controller controller) { - l.v6('Controller | ${controller.runtimeType} | Created'); + l.v6('Controller | ${controller.name}.new'); } @override void onDispose(Controller controller) { - l.v5('Controller | ${controller.runtimeType} | Disposed'); + l.v5('Controller | ${controller.name}.dispose'); + } + + @override + void onHandler(HandlerContext context) { + l.d( + 'Controller | ' '${context.controller.name}.${context.name}', + context.meta, + ); } @override void onStateChanged( - StateController controller, S prevState, S nextState) { - l.d('StateController | ${controller.runtimeType} | $prevState -> $nextState'); + StateController controller, + S prevState, + S nextState, + ) { + final context = Controller.context; + if (context == null) { + // State change occurred outside of the handler + l.d( + 'StateController | ' + '${controller.name} | ' + '$prevState -> $nextState', + ); + } else { + // State change occurred inside the handler + l.d( + 'StateController | ' + '${controller.name}.${context.name} | ' + '$prevState -> $nextState', + context.meta, + ); + } } @override void onError(Controller controller, Object error, StackTrace stackTrace) { - l.w('Controller | ${controller.runtimeType} | $error', stackTrace); + final context = Controller.context; + if (context == null) { + // Error occurred outside of the handler + l.w( + 'Controller | ' + '${controller.name} | ' + '$error', + stackTrace, + ); + } else { + // Error occurred inside the handler + l.w( + 'Controller | ' + '${controller.name}.${context.name} | ' + '$error', + stackTrace, + context.meta, + ); + } } } diff --git a/example/pubspec.lock b/example/pubspec.lock index 55a5057..095c9a0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -141,17 +141,17 @@ packages: dependency: "direct main" description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" control: dependency: "direct main" description: path: ".." relative: true source: path - version: "0.1.0" + version: "0.2.0" convert: dependency: "direct main" description: @@ -322,26 +322,26 @@ packages: dependency: "direct main" description: name: l - sha256: "773c20d824bc2c4585f59313abeb4a0a46f68d82bfdc9eacea27ae6b7cd72478" + sha256: c015d97a7d1552706be9b2367facd2c379ffdabf1e104bc19d284ae775fc9016 url: "https://pub.dev" source: hosted - version: "4.1.0-pre.1" + version: "5.0.0-pre.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -562,7 +562,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.0" + version: "0.0.99" source_span: dependency: transitive description: @@ -599,10 +599,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.2.0" sync_http: dependency: transitive description: @@ -623,10 +623,10 @@ packages: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.2" timing: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1ee7ee7..ce9cb0d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -48,7 +48,7 @@ dependencies: convert: any # Logger - l: ^4.1.0-pre.1 + l: ^5.0.0-pre.2 # Storage shared_preferences: ^2.2.2 diff --git a/lib/src/concurrency/concurrent_controller_handler.dart b/lib/src/concurrency/concurrent_controller_handler.dart index cde6181..b2597aa 100644 --- a/lib/src/concurrency/concurrent_controller_handler.dart +++ b/lib/src/concurrency/concurrent_controller_handler.dart @@ -20,7 +20,7 @@ base mixin ConcurrentControllerHandler on Controller { /// [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. - /// [context] is an optional HashMap of context data to be passed to the zone. + /// [meta] is an optional HashMap of context data to be passed to the zone. @override @protected @mustCallSuper @@ -29,7 +29,7 @@ base mixin ConcurrentControllerHandler on Controller { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, - Map? context, + Map? meta, }) { if (isDisposed) return Future.value(null); _$processingCalls++; @@ -67,16 +67,18 @@ base mixin ConcurrentControllerHandler on Controller { final handlerContext = HandlerContextImpl( controller: this, - name: name ?? '$runtimeType.handler#${handler.runtimeType}', + name: name ?? 'handler#${handler.runtimeType}', completer: completer, - context: { - ...?context, + meta: { + ...?meta, }, ); runZonedGuarded( () async { try { + if (isDisposed) return; + Controller.observer?.onHandler(handlerContext); await handler(); } on Object catch (error, stackTrace) { await onError(error, stackTrace); diff --git a/lib/src/concurrency/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart index 20713f6..4f034b1 100644 --- a/lib/src/concurrency/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -17,7 +17,7 @@ base mixin DroppableControllerHandler on Controller { /// [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. - /// [context] is an optional HashMap of context data to be passed to the zone. + /// [meta] is an optional HashMap of context data to be passed to the zone. @override @protected @mustCallSuper @@ -26,7 +26,7 @@ base mixin DroppableControllerHandler on Controller { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, - Map? context, + Map? meta, }) { if (isDisposed || isProcessing) return Future.value(null); _$processingCalls++; @@ -64,16 +64,18 @@ base mixin DroppableControllerHandler on Controller { final handlerContext = HandlerContextImpl( controller: this, - name: name ?? '$runtimeType.handler#${handler.runtimeType}', + name: name ?? 'handler#${handler.runtimeType}', completer: completer, - context: { - ...?context, + meta: { + ...?meta, }, ); runZonedGuarded( () async { try { + if (isDisposed) return; + Controller.observer?.onHandler(handlerContext); await handler(); } on Object catch (error, stackTrace) { await onError(error, stackTrace); diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index 9dc37a4..41f505a 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -19,7 +19,7 @@ base mixin SequentialControllerHandler on Controller { /// [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. - /// [context] is an optional HashMap of context data to be passed to the zone. + /// [meta] is an optional HashMap of context data to be passed to the zone. @override @protected @mustCallSuper @@ -28,7 +28,7 @@ base mixin SequentialControllerHandler on Controller { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, - Map? context, + Map? meta, }) => _eventQueue.push( () { @@ -61,10 +61,10 @@ base mixin SequentialControllerHandler on Controller { final handlerContext = HandlerContextImpl( controller: this, - name: name ?? '$runtimeType.handler#${handler.runtimeType}', + name: name ?? 'handler#${handler.runtimeType}', completer: completer, - context: { - ...?context, + meta: { + ...?meta, }, ); @@ -75,8 +75,9 @@ base mixin SequentialControllerHandler on Controller { runZonedGuarded( () async { - if (isDisposed) return; try { + if (isDisposed) return; + Controller.observer?.onHandler(handlerContext); await handler(); } on Object catch (error, stackTrace) { await onError(error, stackTrace); diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 6a83112..340ed7a 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -13,6 +13,11 @@ import 'package:meta/meta.dart'; /// Do not implement this interface directly, instead extend [Controller]. @internal abstract interface class IController implements Listenable { + /// The name of the controller. + /// By default, it is the runtime type of the controller. + /// Override this property to provide a custom name. + String get name; + /// Whether the controller is permanently disposed bool get isDisposed; @@ -36,7 +41,7 @@ abstract interface class IController implements Listenable { /// sequentially, concurrently, dropped and etc. /// /// The [name] parameter is used to identify the handler. - /// The [context] parameter is used to pass additional + /// The [meta] parameter is used to pass additional /// information to the handler's zone. /// /// See: @@ -46,7 +51,7 @@ abstract interface class IController implements Listenable { void handle( Future Function() handler, { String? name, - Map? context, + Map? meta, }); } @@ -58,7 +63,10 @@ abstract interface class IControllerObserver { /// Called when the controller is disposed. void onDispose(Controller controller); - /// Called on any state change in the controller. + /// Called when the controller is handling a request. + void onHandler(HandlerContext context); + + /// Called on any state change in the [StateController]. void onStateChanged( StateController controller, S prevState, S nextState); @@ -81,8 +89,7 @@ abstract base class Controller with ChangeNotifier implements IController { } /// Get the handler's context from the current zone. - static HandlerContext? getContext(Controller controller) => - HandlerContext.zoned(); + static HandlerContext? get context => HandlerContext.zoned(); /// Controller observer static IControllerObserver? observer; @@ -94,6 +101,9 @@ abstract base class Controller with ChangeNotifier implements IController { List.unmodifiable(listenables.whereType()), ); + @override + String get name => runtimeType.toString(); + @override bool get isDisposed => _$isDisposed; bool _$isDisposed = false; @@ -113,13 +123,13 @@ abstract base class Controller with ChangeNotifier implements IController { /// /// [handler] is the main operation to be executed. /// [name] is an optional name for the operation, used for debugging. - /// [context] is an optional HashMap of context data to be passed to the zone. + /// [meta] is an optional HashMap of context data to be passed to the zone. @protected @override Future handle( Future Function() handler, { String? name, - Map? context, + Map? meta, }); @protected diff --git a/lib/src/handler_context.dart b/lib/src/handler_context.dart index 61712f4..d53ad99 100644 --- a/lib/src/handler_context.dart +++ b/lib/src/handler_context.dart @@ -27,7 +27,7 @@ abstract interface class HandlerContext { bool get isDone; /// Extra meta information about the handler. - abstract final Map context; + abstract final Map meta; } @internal @@ -35,7 +35,7 @@ final class HandlerContextImpl implements HandlerContext { HandlerContextImpl( {required this.controller, required this.name, - required this.context, + required this.meta, required Completer completer}) : _completer = completer; @@ -54,5 +54,5 @@ final class HandlerContextImpl implements HandlerContext { final Completer _completer; @override - final Map context; + final Map meta; } diff --git a/lib/src/state_controller.dart b/lib/src/state_controller.dart index 147bbae..02490b3 100644 --- a/lib/src/state_controller.dart +++ b/lib/src/state_controller.dart @@ -30,8 +30,7 @@ abstract base class StateController extends Controller StateController({required S initialState}) : _$state = initialState; /// Get the handler's context from the current zone. - static HandlerContext? getContext(Controller controller) => - HandlerContext.zoned(); + static HandlerContext? get context => HandlerContext.zoned(); @override @nonVirtual diff --git a/test/control_test.dart b/test/control_test.dart index a91789a..6fc123e 100644 --- a/test/control_test.dart +++ b/test/control_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'unit/handler_context_test.dart' as handler_context_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; @@ -9,6 +10,7 @@ import 'widget/state_consumer_test.dart' as state_consumer_test; void main() { group('unit', () { state_controller_test.main(); + handler_context_test.main(); }); group('widget', () { diff --git a/test/unit/handler_context_test.dart b/test/unit/handler_context_test.dart new file mode 100644 index 0000000..69d31c2 --- /dev/null +++ b/test/unit/handler_context_test.dart @@ -0,0 +1,142 @@ +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; + await controller.event( + meta: {'double': value}, + out: (ctx) => lastContext = ctx, + ); + 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), + ), + ), + ), + ); + expect(observer.lastErrorContext, isNull); + expect(Controller.context, isNull); + + controller.dispose(); + } + }); + }); + +final class _FakeControllerObserver implements IControllerObserver { + HandlerContext? lastContext; + HandlerContext? lastStateContext; + HandlerContext? lastErrorContext; + + @override + void onCreate(Controller controller) {/* ignore */} + + @override + void onDispose(Controller controller) {/* ignore */} + + @override + void onHandler(HandlerContext context) { + lastContext = context; + } + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) { + lastStateContext = Controller.context; + } + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + lastErrorContext = Controller.context; + } +} + +abstract base class _FakeControllerBase extends StateController { + _FakeControllerBase() : super(initialState: false); + + Future event({ + Map? meta, + void Function(HandlerContext context)? out, + }) { + final eventMeta = { + ...?meta, + 'started_at': DateTime.now(), + }; + return handle( + () async { + 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: eventMeta, + ); + } +} + +final class _FakeControllerSequential = _FakeControllerBase + with SequentialControllerHandler; + +final class _FakeControllerDroppable = _FakeControllerBase + with DroppableControllerHandler; + +final class _FakeControllerConcurrent = _FakeControllerBase + with ConcurrentControllerHandler; From 93d0fa68d9bd1b1dc91b1fb58b61fdaaf01de810 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 16:57:36 +0400 Subject: [PATCH 07/12] Refactor concurrent controller handlers to simplify code --- test/unit/handler_context_test.dart | 46 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/test/unit/handler_context_test.dart b/test/unit/handler_context_test.dart index 69d31c2..c7398ce 100644 --- a/test/unit/handler_context_test.dart +++ b/test/unit/handler_context_test.dart @@ -106,30 +106,28 @@ abstract base class _FakeControllerBase extends StateController { Future event({ Map? meta, void Function(HandlerContext context)? out, - }) { - final eventMeta = { - ...?meta, - 'started_at': DateTime.now(), - }; - return handle( - () async { - 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: eventMeta, - ); - } + }) => + handle( + () async { + 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(), + }, + ); } final class _FakeControllerSequential = _FakeControllerBase From f03e2bfab269438a74930a01eaeb8a675b2942ee Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 17:06:11 +0400 Subject: [PATCH 08/12] Add HandlerContext to handlers, name getter for Controller, and onHandler method to IControllerObserver --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdcf5b..34d8eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.2.0 + +- **ADDED**: `HandlerContext` to handlers, available at zone and observer. +- **ADDED**: `name` getter for `Controller` +- **ADDED**: `void onHandler(HandlerContext context)` to `IControllerObserver` +- **REMOVED**: `done` getter from `Controller` + ## 0.1.0 - **BREAKING CHANGE**: Replace FutureOr with Future in handler From 3e545a813b1efdd7f42583edd6c131369511e11a Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 17:08:19 +0400 Subject: [PATCH 09/12] Fix setup action placement --- .github/{ => actions}/setup/action.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ => actions}/setup/action.yaml (100%) diff --git a/.github/setup/action.yaml b/.github/actions/setup/action.yaml similarity index 100% rename from .github/setup/action.yaml rename to .github/actions/setup/action.yaml From 999f351ec3f4e90675249271dcf1ec9d23921f08 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 17:11:47 +0400 Subject: [PATCH 10/12] Refactor ControllerScope$Element constructor in controller_scope.dart --- lib/src/controller_scope.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/controller_scope.dart b/lib/src/controller_scope.dart index 92cf397..0d3d190 100644 --- a/lib/src/controller_scope.dart +++ b/lib/src/controller_scope.dart @@ -81,7 +81,7 @@ class ControllerScope extends InheritedWidget { @internal final class ControllerScope$Element extends InheritedElement { - ControllerScope$Element(ControllerScope widget) : super(widget); + ControllerScope$Element(ControllerScope super.widget); @nonVirtual _ControllerDependency get _dependency => From 4b91e8cf10faead43c3d4ac57725cd67b7f9ab77 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 17:20:58 +0400 Subject: [PATCH 11/12] Refactor Makefile and pubspec.yaml files --- Makefile | 98 +++++++++++++++++++++++++++++++++++++++++--- example/pubspec.yaml | 2 +- pubspec.yaml | 2 +- tool/makefile/pub.mk | 57 -------------------------- 4 files changed, 95 insertions(+), 64 deletions(-) delete mode 100644 tool/makefile/pub.mk diff --git a/Makefile b/Makefile index 0fd7f6a..f46fc28 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,95 @@ +SHELL :=/bin/bash -e -o pipefail +PWD := $(shell pwd) + +.DEFAULT_GOAL := all +.PHONY: all +all: ## build pipeline +all: format check test + +.PHONY: ci +ci: ## CI build pipeline +ci: all + +.PHONY: precommit +precommit: ## validate the branch before commit +precommit: all + .PHONY: help -help: ## help dialog - @echo 'Usage: make ... ' - @echo '' - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' +help: + @echo 'Usage: make ... ' + @echo '' + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: version +version: ## Check flutter version + @flutter --version + +.PHONY: doctor +doctor: ## Check flutter doctor + @flutter doctor + +.PHONY: format +format: ## Format the code + @dart format -l 80 --fix lib/ test/ + +.PHONY: fmt +fmt: format + +.PHONY: fix +fix: format ## Fix the code + @dart fix --apply lib + @dart fix --apply test + +.PHONY: get +get: ## Get the dependencies + @flutter pub get + +.PHONY: upgrade +upgrade: get ## Upgrade dependencies + @flutter pub upgrade + +.PHONY: upgrade-major +upgrade-major: get ## Upgrade to major versions + @flutter pub upgrade --major-versions + +.PHONY: outdated +outdated: get ## Check for outdated dependencies + @flutter pub outdated --show-all --dev-dependencies --dependency-overrides --transitive --no-prereleases + +.PHONY: dependencies +dependencies: get ## Check outdated dependencies + @flutter pub outdated --dependency-overrides \ + --dev-dependencies --prereleases --show-all --transitive + +.PHONY: test +test: get ## Run the tests + @flutter test --coverage --concurrency=6 test/control_test.dart + +.PHONY: publish-check +publish-check: ## Check the package before publishing + @dart pub publish --dry-run + +.PHONY: publish +publish: ## Publish the package + @yes | dart pub publish + +.PHONY: analyze +analyze: get ## Analyze the code + @dart format --set-exit-if-changed -l 80 -o none lib/ test/ + @flutter analyze --fatal-infos --fatal-warnings lib/ test/ + +.PHONY: check +check: analyze publish-check ## Check the code +# @flutter pub global activate pana +# @pana --json --no-warning --line-length 80 > log.pana.json + +.PHONY: clean +clean: ## Clean the project and remove all generated files + @rm -rf dist bin out build + @rm -rf coverage.* coverage .dart_tool .packages pubspec.lock --include tool/makefile/pub.mk tool/makefile/test.mk +.PHONY: diff +diff: ## git diff + $(call print-target) + @git diff --exit-code + @RES=$$(git status --porcelain) ; if [ -n "$$RES" ]; then echo $$RES && exit 1 ; fi diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ce9cb0d..b0f003d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -32,7 +32,7 @@ platforms: version: 1.0.0+1 environment: - sdk: '>=3.2.0 <4.0.0' + sdk: '>=3.4.0 <4.0.0' flutter: ">=3.16.0" dependencies: diff --git a/pubspec.yaml b/pubspec.yaml index 0c88b47..308e5ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,7 @@ platforms: environment: sdk: '>=3.4.0 <4.0.0' - flutter: ">=3.0.0" + flutter: ">=3.16.0" dependencies: flutter: diff --git a/tool/makefile/pub.mk b/tool/makefile/pub.mk deleted file mode 100644 index 89c2c0f..0000000 --- a/tool/makefile/pub.mk +++ /dev/null @@ -1,57 +0,0 @@ -.PHONY : version -version: ## Check flutter version - @flutter --version - -.PHONY : doctor -doctor: ## Check flutter doctor - @flutter doctor - -.PHONY : clean -clean: ## Clean all generated files - @rm -rf coverage .dart_tool .packages pubspec.lock - -.PHONY : get -get: ## Get dependencies - @flutter pub get - -.PHONY : fix -fix: format - @dart fix --apply lib - -.PHONY : gen -gen: codegen ## Generate all - -.PHONY : upgrade -upgrade: ## Upgrade dependencies - @flutter pub upgrade - -.PHONY : upgrade-major -upgrade-major: ## Upgrade to major versions - @flutter pub upgrade --major-versions - -.PHONY : outdated -outdated: get ## Check outdated dependencies - @flutter pub outdated - -.PHONY : dependencies -dependencies: upgrade ## Check outdated dependencies - @flutter pub outdated --dependency-overrides \ - --dev-dependencies --prereleases --show-all --transitive - -.PHONY : format -format: ## Format code - @dart format --fix -l 80 . - -.PHONY : analyze -analyze: get format ## Analyze code - @dart analyze --fatal-infos --fatal-warnings - -.PHONY : check -check: analyze test ## Check code - @dart pub publish --dry-run - @dart pub global activate pana - @pana --json --no-warning --line-length 80 > log.pana.json - -.PHONY : publish -publish: ## Publish package - @dart pub publish From 5238615d6efa0eb73dbc82f0d2ced97a88f1e8fd Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 16 Oct 2024 17:21:11 +0400 Subject: [PATCH 12/12] Refactor test.mk file: Remove unused makefile targets for running tests --- tool/makefile/test.mk | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 tool/makefile/test.mk diff --git a/tool/makefile/test.mk b/tool/makefile/test.mk deleted file mode 100644 index 2ec1b15..0000000 --- a/tool/makefile/test.mk +++ /dev/null @@ -1,14 +0,0 @@ -.PHONY: integration -integration: ## Run integration tests - @(cd example && flutter test \ - --coverage \ - integration_test/app_test.dart) || (echo "Error while running integration tests"; exit 1) - -.PHONY: test -test: ## Run unit tests - @flutter test test/control_test.dart --coverage || (echo "Error while running tests"; exit 1) - @genhtml coverage/lcov.info --output=.coverage -o .coverage/html || (echo "Error while running unit tests"; exit 2) - -.PHONY: genhtml -genhtml: ## Generate coverage html - @genhtml coverage/lcov.info -o coverage/html || (echo "Error while running genhtml"; exit 1)