diff --git a/lib/octopus.dart b/lib/octopus.dart index c91645e..add206e 100644 --- a/lib/octopus.dart +++ b/lib/octopus.dart @@ -1,5 +1,6 @@ library octopus; +export 'src/controller/guard.dart'; export 'src/controller/octopus.dart'; export 'src/state/state.dart' show OctopusState, OctopusNode, OctopusRoute; export 'src/widget/route_context.dart'; diff --git a/lib/src/controller/delegate.dart b/lib/src/controller/delegate.dart index 5bf0536..75e4241 100644 --- a/lib/src/controller/delegate.dart +++ b/lib/src/controller/delegate.dart @@ -1,9 +1,12 @@ +import 'dart:collection'; import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; +import 'package:octopus/src/controller/guard.dart'; import 'package:octopus/src/controller/octopus.dart'; +import 'package:octopus/src/controller/state_queue.dart'; import 'package:octopus/src/state/state.dart'; import 'package:octopus/src/widget/octopus_navigator.dart'; @@ -17,22 +20,30 @@ final class OctopusDelegate extends RouterDelegate OctopusDelegate({ required OctopusState initialState, required Map routes, + required OctopusRoute defaultRoute, + List? history, + List? guards, String? restorationScopeId = 'octopus', List? observers, TransitionDelegate? transitionDelegate, RouteFactory? notFound, void Function(Object error, StackTrace stackTrace)? onError, - }) : _stateObserver = _OctopusStateObserver(initialState), + }) : _stateObserver = _OctopusStateObserver(initialState, history), _routes = routes, + _defaultRoute = defaultRoute, + _guards = guards?.toList(growable: false) ?? [], _restorationScopeId = restorationScopeId, _observers = observers, _transitionDelegate = transitionDelegate ?? const DefaultTransitionDelegate(), _notFound = notFound, - _onError = onError; + _onError = onError { + // Subscribe to the guards. + _guardsListener = Listenable.merge(_guards)..addListener(_onGuardsNotified); + } final _OctopusStateObserver _stateObserver; - ValueListenable get stateObserver => _stateObserver; + OctopusStateObserver get stateObserver => _stateObserver; /// The restoration scope id for the navigator. final String? _restorationScopeId; @@ -56,6 +67,13 @@ final class OctopusDelegate extends RouterDelegate /// Routes hash table. final Map _routes; + /// Default fallback route. + final OctopusRoute _defaultRoute; + + /// Guards. + final List _guards; + late final Listenable _guardsListener; + /// WidgetApp's navigator. NavigatorState? get navigator => _modalObserver.navigator; final NavigatorObserver _modalObserver = RouteObserver>(); @@ -73,10 +91,6 @@ final class OctopusDelegate extends RouterDelegate } } - /// Current configuration. - @override - OctopusState get currentConfiguration => _stateObserver.value; - @override Widget build(BuildContext context) => OctopusNavigator( controller: $controller.target!, @@ -107,20 +121,35 @@ final class OctopusDelegate extends RouterDelegate (_, __) => false, ); + @internal List> buildPagesFromNodes( - BuildContext context, List nodes) => + BuildContext context, + List nodes, + ) => _handleErrors(() { final pages = >[]; + // Build pages for (final node in nodes) { - final route = _routes[node.name]; - assert(route != null, 'Route ${node.name} not found'); - if (route == null) continue; - final page = route.pageBuilder(context, node); - pages.add(page); + try { + final route = _routes[node.name]; + assert(route != null, 'Route ${node.name} not found'); + if (route == null) continue; + final page = route.pageBuilder(context, node); + pages.add(page); + } on Object catch (error, stackTrace) { + developer.log( + 'Failed to build page', + name: 'octopus', + error: error, + stackTrace: stackTrace, + level: 1000, + ); + _onError?.call(error, stackTrace); + } } if (pages.isNotEmpty) return pages; - throw FlutterError('The Navigator.pages must not be empty to use the ' - 'Navigator.pages API'); + // Build default page if no pages were built + return >[_defaultRoute.pageBuilder(context, nodes.first)]; }, (error, stackTrace) { developer.log( 'Failed to build pages', @@ -169,32 +198,32 @@ final class OctopusDelegate extends RouterDelegate () { final route = _notFound?.call(settings); if (route != null) return route; - /* _onError?.call( - OctopusUnknownRouteException(settings), + developer.log( + 'Unknown route ${settings.name}', + name: 'octopus', + level: 1000, + stackTrace: StackTrace.current, + ); + _onError?.call( + 'Unknown route ${settings.name}', StackTrace.current, - ); */ + ); return null; }, (_, __) => null, ); - OctopusState? _$newConfigurationCache; + late final OctopusStateQueue _$stateChangeQueue = + OctopusStateQueue(processor: _setConfiguration); + @override - Future setNewRoutePath(covariant OctopusState configuration) { - if (configuration.children.isEmpty) { - //assert(false, 'Configuration should not be empty'); - return SynchronousFuture(null); - } - _handleErrors(() { - OctopusState? newConfiguration = configuration; - // TODO(plugfox): validate and normalize configuration - _stateObserver.changeState(newConfiguration); - notifyListeners(); - }, (_, __) {}); - - // Use [SynchronousFuture] so that the initial url is processed - // synchronously and remove unwanted initial animations on deep-linking - return SynchronousFuture(null); + Future setNewRoutePath(covariant OctopusState configuration) async { + // Normalize configuration + // ... + // Validate configuration + if (configuration.children.isEmpty) return; + // Add configuration to the queue + return _$stateChangeQueue.add(configuration); } @override @@ -208,27 +237,98 @@ final class OctopusDelegate extends RouterDelegate if (configuration.children.isEmpty) return SynchronousFuture(null); return setNewRoutePath(configuration); } + + // Called when the one of the guards changed. + void _onGuardsNotified() { + setNewRoutePath(_stateObserver.value); + } + + /// DO NOT USE THIS METHOD DIRECTLY. + /// Use [setNewRoutePath] instead. + /// Used for OctopusStateQueue. + /// + /// {@nodoc} + @protected + @nonVirtual + Future _setConfiguration(OctopusState configuration) => + _handleErrors(() async { + var newConfiguration = configuration; + final history = _stateObserver.history; + for (final guard in _guards) { + try { + final result = await guard(history, newConfiguration); + if (result == null) return; + newConfiguration = result; + } on Object catch (error, stackTrace) { + developer.log( + 'Guard ${guard.runtimeType} failed', + name: 'octopus', + error: error, + stackTrace: stackTrace, + level: 1000, + ); + _onError?.call(error, stackTrace); + return; // Cancel navigation + } + } + + if (_stateObserver._changeState(newConfiguration)) { + notifyListeners(); + } + }, (_, __) => SynchronousFuture(null)); + + @override + void dispose() { + _guardsListener.removeListener(_onGuardsNotified); + super.dispose(); + } +} + +/// Octopus state observer. +abstract interface class OctopusStateObserver + implements ValueListenable { + /// Current immutable state. + @override + T get value; + + /// History of the states. + List get history; } final class _OctopusStateObserver with ChangeNotifier - implements ValueListenable { - _OctopusStateObserver(OctopusState initialState) - : _value = OctopusState$Immutable.from(initialState); + implements OctopusStateObserver { + _OctopusStateObserver(OctopusState initialState, + [List? history]) + : _value = OctopusState$Immutable.from(initialState), + _history = history?.toList() ?? [] { + // Add the initial state to the history. + if (_history.isEmpty || _history.last != initialState) { + _history.add(initialState); + } + } @protected @nonVirtual OctopusState$Immutable _value; + @protected + @nonVirtual + final List _history; + + @override + List get history => + UnmodifiableListView(_history); + @override OctopusState$Immutable get value => _value; - @internal @nonVirtual - void changeState(OctopusState? state) { - if (state == null) return; - if (state.children.isEmpty) return; + bool _changeState(OctopusState state) { + if (state.children.isEmpty) return false; _value = OctopusState$Immutable.from(state); + _history.add(_value); notifyListeners(); + return true; } } diff --git a/lib/src/controller/guard.dart b/lib/src/controller/guard.dart new file mode 100644 index 0000000..0fa21f3 --- /dev/null +++ b/lib/src/controller/guard.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:octopus/src/state/state.dart' show OctopusState; + +/// Guard for the router. +/// +/// {@template guard} +/// This is good place for checking permissions, authentication, etc. +/// +/// Return the new state or null to cancel navigation. +/// If the returned state is null or throw an error, +/// the router will not change the state at all. +/// +/// You should return the same state if you don't want to change it and +/// continue navigation. +/// +/// You should return the new state if you want to change it and +/// continue navigation. +/// +/// You should return null if you want to cancel navigation. +/// +/// If something changed in app state, you should notify the guard +/// and router rerun the all guards with current state. +/// {@endtemplate} +abstract interface class IOctopusGuard implements Listenable { + /// Called when the [OctopusState] changes. + /// + /// [history] is the history of the [OctopusState] states. + /// [state] is the expected new state. + /// + /// {@macro guard} + FutureOr call(List history, OctopusState state); +} + +/// {@macro guard} +class OctopusGuard with ChangeNotifier implements IOctopusGuard { + /// {@macro guard} + OctopusGuard(); + + @override + FutureOr call( + List history, + OctopusState state, + ) => + state; +} diff --git a/lib/src/controller/information_parser.dart b/lib/src/controller/information_parser.dart index 81b147a..dedb9b6 100644 --- a/lib/src/controller/information_parser.dart +++ b/lib/src/controller/information_parser.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'package:octopus/src/controller/state_codec.dart'; import 'package:octopus/src/state/state.dart'; +import 'package:octopus/src/state/state_codec.dart'; /// Converts between [RouteInformation] and [OctopusState]. /// {@nodoc} diff --git a/lib/src/controller/octopus.dart b/lib/src/controller/octopus.dart index d2fefed..0e92646 100644 --- a/lib/src/controller/octopus.dart +++ b/lib/src/controller/octopus.dart @@ -1,12 +1,12 @@ import 'dart:collection'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:octopus/src/controller/delegate.dart'; +import 'package:octopus/src/controller/guard.dart'; import 'package:octopus/src/controller/information_parser.dart'; import 'package:octopus/src/controller/information_provider.dart'; import 'package:octopus/src/state/state.dart'; -import 'package:octopus/src/utils/state_util.dart'; +import 'package:octopus/src/state/state_util.dart'; import 'package:octopus/src/widget/octopus_navigator.dart'; /// {@template octopus} @@ -17,7 +17,10 @@ abstract base class Octopus { /// {@macro octopus} factory Octopus({ required List routes, - OctopusRoute? home, + OctopusRoute? defaultRoute, + List? guards, + OctopusState? initialState, + List? history, String? restorationScopeId, List? observers, TransitionDelegate? transitionDelegate, @@ -37,20 +40,21 @@ abstract base class Octopus { /// A convenient bundle to configure a [Router] widget. final OctopusConfig config; + /// State observer, + /// which can be used to listen to changes in the [OctopusState]. + OctopusStateObserver get stateObserver; + /// Current state. OctopusState get state; - /// State observer, - /// which can be used to listen to changes in the [OctopusState]. - ValueListenable get stateObserver; + /// History of the [OctopusState] states. + List get history; /// Set new state and rebuild the navigation tree if needed. void setState(OctopusState Function(OctopusState state) change); /// Navigate to the specified location. void navigate(String location); - - // TODO(plugfox): history } /// {@nodoc} @@ -59,7 +63,10 @@ final class _OctopusImpl extends Octopus /// {@nodoc} factory _OctopusImpl({ required List routes, - OctopusRoute? home, + OctopusRoute? defaultRoute, + List? guards, + OctopusState? initialState, + List? history, String? restorationScopeId = 'octopus', List? observers, TransitionDelegate? transitionDelegate, @@ -68,7 +75,7 @@ final class _OctopusImpl extends Octopus }) { assert(routes.isNotEmpty, 'Routes list should contain at least one route'); final list = List.of(routes); - final defaultRoute = home ?? list.firstOrNull; + defaultRoute ??= list.firstOrNull; if (defaultRoute == null) { final error = StateError('Routes list should contain at least one route'); onError?.call(error, StackTrace.current); @@ -87,11 +94,15 @@ final class _OctopusImpl extends Octopus }, ); final routerDelegate = OctopusDelegate( - initialState: OctopusState$Immutable( - children: [defaultRoute.node()], - arguments: const {}, - ), + initialState: initialState?.freeze() ?? + OctopusState$Immutable( + children: [defaultRoute.node()], + arguments: const {}, + ), + history: history, routes: routesTable, + defaultRoute: defaultRoute, + guards: guards, restorationScopeId: restorationScopeId, observers: observers, transitionDelegate: transitionDelegate, @@ -126,16 +137,18 @@ final class _OctopusImpl extends Octopus ); @override - OctopusState get state => config.routerDelegate.currentConfiguration; + OctopusStateObserver get stateObserver => config.routerDelegate.stateObserver; + + @override + OctopusState get state => stateObserver.value; @override - ValueListenable get stateObserver => - config.routerDelegate.stateObserver; + List get history => stateObserver.history; } base mixin _OctopusDelegateOwner on Octopus { @override - abstract final ValueListenable stateObserver; + abstract final OctopusStateObserver stateObserver; } base mixin _OctopusNavigationMixin on Octopus { diff --git a/lib/src/controller/state_queue.dart b/lib/src/controller/state_queue.dart new file mode 100644 index 0000000..3a92d8c --- /dev/null +++ b/lib/src/controller/state_queue.dart @@ -0,0 +1,112 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:developer' as developer; + +import 'package:meta/meta.dart'; +import 'package:octopus/src/state/state.dart'; + +/// {@nodoc} +@internal +class OctopusStateQueue implements Sink { + /// {@nodoc} + OctopusStateQueue( + {required Future Function(OctopusState state) processor, + String debugLabel = 'OctopusStateQueue'}) + : _stateProcessor = processor, + _debugLabel = debugLabel; + + final DoubleLinkedQueue<_StateTask> _queue = DoubleLinkedQueue<_StateTask>(); + final Future Function(OctopusState state) _stateProcessor; + final String _debugLabel; + Future? _processing; + bool get isClosed => _closed; + bool _closed = false; + + @override + Future add(OctopusState state) { + if (_closed) throw StateError('OctopusStateQueue is closed'); + final task = _StateTask(state); + _queue.add(task); + _start(); + developer.Timeline.instantSync('$_debugLabel:add'); + return task.future; + } + + @override + Future close({bool force = false}) async { + _closed = true; + if (force) { + for (final task in _queue) { + task.reject( + StateError('OctopusStateQueue is closed'), + StackTrace.current, + ); + } + _queue.clear(); + } else { + await _processing; + } + } + + Future _start() { + final processing = _processing; + if (processing != null) return processing; + final flow = developer.Flow.begin(); + developer.Timeline.instantSync('$_debugLabel:begin'); + return _processing = Future.doWhile(() async { + if (_queue.isEmpty) { + _processing = null; + developer.Timeline.instantSync('$_debugLabel:end'); + developer.Flow.end(flow.id); + return false; + } + try { + await developer.Timeline.timeSync( + '$_debugLabel:task', + () => _queue.removeFirst()(_stateProcessor), + flow: developer.Flow.step(flow.id), + ); + } on Object catch (error, stackTrace) { + developer.log( + 'Failed to process state', + name: 'octopus', + error: error, + stackTrace: stackTrace, + level: 1000, + ); + } + return true; + }); + } +} + +@immutable +class _StateTask { + _StateTask(OctopusState state) + : _state = state, + _completer = Completer(); + + final OctopusState _state; + final Completer _completer; + + /// {@nodoc} + Future get future => _completer.future; + + /// {@nodoc} + Future call(Future Function(OctopusState) fn) async { + try { + if (_completer.isCompleted) return; + await fn(_state); + if (_completer.isCompleted) return; + _completer.complete(); + } on Object catch (error, stackTrace) { + _completer.completeError(error, stackTrace); + } + } + + /// {@nodoc} + void reject(Object error, [StackTrace? stackTrace]) { + if (_completer.isCompleted) return; // coverage:ignore-line + _completer.completeError(error, stackTrace); + } +} diff --git a/lib/src/state/state.dart b/lib/src/state/state.dart index 6440166..6a193ca 100644 --- a/lib/src/state/state.dart +++ b/lib/src/state/state.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'package:flutter/material.dart' show MaterialPage; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'package:octopus/src/utils/state_util.dart'; +import 'package:octopus/src/state/state_util.dart'; /// Signature for the callback to [OctopusNode.visitChildNodes]. /// diff --git a/lib/src/controller/state_codec.dart b/lib/src/state/state_codec.dart similarity index 100% rename from lib/src/controller/state_codec.dart rename to lib/src/state/state_codec.dart diff --git a/lib/src/utils/state_util.dart b/lib/src/state/state_util.dart similarity index 100% rename from lib/src/utils/state_util.dart rename to lib/src/state/state_util.dart diff --git a/test/src/state_test.dart b/test/src/state_test.dart index 20ed5b3..a5deab4 100644 --- a/test/src/state_test.dart +++ b/test/src/state_test.dart @@ -1,7 +1,7 @@ // ignore_for_file: avoid_print import 'package:flutter_test/flutter_test.dart'; -import 'package:octopus/src/utils/state_util.dart'; +import 'package:octopus/src/state/state_util.dart'; void main() => group('state', () { test('decode_url', () {