diff --git a/lib/octopus.dart b/lib/octopus.dart index dc9a8bf..5be6093 100644 --- a/lib/octopus.dart +++ b/lib/octopus.dart @@ -1,9 +1,9 @@ library octopus; -export 'src/controller/delegate.dart' - show OctopusStateObserver, OctopusHistoryEntry; +export 'src/controller/controller.dart'; +export 'src/controller/delegate.dart'; export 'src/controller/guard.dart'; -export 'src/controller/octopus.dart'; +export 'src/controller/observer.dart'; export 'src/state/state.dart'; export 'src/widget/bucket_navigator.dart'; export 'src/widget/build_context_extension.dart'; diff --git a/lib/src/controller/config.dart b/lib/src/controller/config.dart new file mode 100644 index 0000000..b713ba7 --- /dev/null +++ b/lib/src/controller/config.dart @@ -0,0 +1,43 @@ +import 'package:flutter/widgets.dart'; +import 'package:octopus/src/controller/delegate.dart'; +import 'package:octopus/src/controller/information_parser.dart'; +import 'package:octopus/src/controller/information_provider.dart'; +import 'package:octopus/src/controller/observer.dart'; +import 'package:octopus/src/state/state.dart'; + +/// {@template octopus_config} +/// Creates a [OctopusConfig] as a [RouterConfig]. +/// {@endtemplate} +class OctopusConfig implements RouterConfig { + /// {@macro octopus_config} + OctopusConfig({ + required this.routes, + required this.routeInformationProvider, + required this.routeInformationParser, + required this.routerDelegate, + required this.backButtonDispatcher, + required this.observer, + }); + + /// The [OctopusRoute]s that are used to configure the [Router]. + final Map routes; + + /// The [RouteInformationProvider] that is used to configure the [Router]. + @override + final OctopusInformationProvider routeInformationProvider; + + /// The [RouteInformationParser] that is used to configure the [Router]. + @override + final OctopusInformationParser routeInformationParser; + + /// The [RouterDelegate] that is used to configure the [Router]. + @override + final OctopusDelegate routerDelegate; + + /// The [BackButtonDispatcher] that is used to configure the [Router]. + @override + final BackButtonDispatcher backButtonDispatcher; + + /// The [OctopusStateObserver] that is used to configure the [Router]. + final OctopusStateObserver observer; +} diff --git a/lib/src/controller/controller.dart b/lib/src/controller/controller.dart new file mode 100644 index 0000000..af11a6b --- /dev/null +++ b/lib/src/controller/controller.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:octopus/src/controller/config.dart'; +import 'package:octopus/src/controller/guard.dart'; +import 'package:octopus/src/controller/navigator/controller.dart'; +import 'package:octopus/src/controller/observer.dart'; +import 'package:octopus/src/controller/singleton.dart'; +import 'package:octopus/src/controller/typedefs.dart'; +import 'package:octopus/src/state/state.dart'; +import 'package:octopus/src/widget/inherited_octopus.dart'; + +/// {@template octopus} +/// The main class of the package. +/// Router configuration is provided by the [routes] parameter. +/// {@endtemplate} +abstract interface class Octopus { + /// {@macro octopus} + factory Octopus({ + required List routes, + OctopusRoute? defaultRoute, + List? guards, + OctopusState? initialState, + List? history, + Codec? codec, + String? restorationScopeId, + List? observers, + TransitionDelegate? transitionDelegate, + NotFoundBuilder? notFound, + void Function(Object error, StackTrace stackTrace)? onError, + }) = Octopus$NavigatorImpl; + + /// Receives the [Octopus] instance from the elements tree. + static Octopus? maybeOf(BuildContext context) => + InheritedOctopus.maybeOf(context, listen: false)?.octopus; + + /// Receives the [Octopus] instance from the elements tree. + static Octopus of(BuildContext context) => + InheritedOctopus.of(context, listen: false).octopus; + + /// Receives the current [OctopusState] instance from the elements tree. + static OctopusState$Immutable stateOf( + BuildContext context, { + bool listen = true, + }) => + InheritedOctopus.of(context, listen: true).state; + + /// Receives the last initializated [Octopus] instance. + static Octopus get instance => + $octopusSingletonInstance ?? _throwOctopusNotInitialized(); + static Never _throwOctopusNotInitialized() => + throw Exception('Octopus is not initialized yet.'); + + /// A convenient bundle to configure a [Router] widget. + abstract final OctopusConfig config; + + /// {@nodoc} + @Deprecated('Renamed to "observer".') + OctopusStateObserver get stateObserver; + + /// State observer, + /// which can be used to listen to changes in the [OctopusState]. + OctopusStateObserver get observer; + + /// Current state. + OctopusState$Immutable get state; + + /// History of the [OctopusState] states. + List get history; + + /// Completes when processing queue is empty + /// and all transactions are completed. + /// This is mean controller is ready to use and in a idle state. + Future get processingCompleted; + + /// Whether the controller is currently processing a tasks. + bool get isProcessing; + + /// Whether the controller is currently idle. + bool get isIdle; + + /// Set new state and rebuild the navigation tree if needed. + /// + /// Better to use [transaction] method to change multiple states + /// at once synchronously at the same time and merge changes into transaction. + Future setState( + OctopusState Function(OctopusState$Mutable state) change); + + /// Navigate to the specified location. + Future navigate(String location); + + /// Execute a synchronous transaction. + /// For example you can use it to change multiple states at once and + /// combine them into one change. + /// + /// [change] is a function that takes the current state as an argument + /// and returns a new state. + /// [priority] is used to determine the order of execution of transactions. + /// The higher the priority, the earlier the transaction will be executed. + /// If the priority is not specified, the transaction will be executed + /// in the order in which it was added. + Future transaction( + OctopusState Function(OctopusState$Mutable state) change, { + int? priority, + }); + + /// Push a new top route to the navigation stack + /// with the specified [arguments]. + Future push(OctopusRoute route, {Map? arguments}); + + /// Push a new top route to the navigation stack + /// with the specified [arguments]. + Future pushNamed( + String name, { + Map? arguments, + }); + + /// Push multiple routes to the navigation stack. + Future pushAll( + List<({OctopusRoute route, Map? arguments})> routes); + + /// Push multiple routes to the navigation stack. + Future pushAllNamed( + List<({String name, Map? arguments})> routes, + ); + + /// Mutate all nodes with a new one. From leaf to root. + Future replaceAll( + OctopusNode Function(OctopusNode$Mutable) fn, { + bool recursive = true, + }); + + /// Replace the last top route in the navigation stack with a new one. + Future upsertLast( + OctopusRoute route, { + Map? arguments, + }); + + /// Replace the last top route in the navigation stack with a new one. + Future upsertLastNamed( + String name, { + Map? arguments, + }); + + /// Pop a one of the top routes from the navigation stack. + /// If the stack contains only one route, close the application. + Future pop(); + + /// Pop a one of the top routes from the navigation stack. + /// If the stack contains only one route, nothing will happen. + Future maybePop(); + + /// Pop all except the first route from the navigation stack. + /// If the stack contains only one route, nothing will happen. + /// Usefull to go back to the "home" route. + Future popAll(); + + /// Pop all routes from the navigation stack until the predicate is true. + /// If the test is not satisfied, + /// the node is not removed and the walk is stopped. + /// [true] - remove node + /// [false] - stop walk and keep node + Future> popUntil(bool Function(OctopusNode node) predicate); + + /// Get a route by name. + OctopusRoute? getRouteByName(String name); + + /// Update state arguments + Future setArguments(void Function(Map args) change); +} diff --git a/lib/src/controller/delegate.dart b/lib/src/controller/delegate.dart index a5a16cd..f4713cc 100644 --- a/lib/src/controller/delegate.dart +++ b/lib/src/controller/delegate.dart @@ -1,572 +1,19 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:developer' as developer; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.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/node_extra_storage.dart'; +import 'package:flutter/widgets.dart'; import 'package:octopus/src/state/state.dart'; -import 'package:octopus/src/util/logs.dart'; -import 'package:octopus/src/util/state_util.dart'; -import 'package:octopus/src/widget/inherited_octopus.dart'; -import 'package:octopus/src/widget/navigator.dart'; -import 'package:octopus/src/widget/no_animation.dart'; - -/// Builder for the unknown route. -typedef NotFoundBuilder = Widget Function( - BuildContext ctx, - String name, - Map arguments, -); /// Octopus delegate. -/// {@nodoc} -@internal -final class OctopusDelegate extends RouterDelegate - with ChangeNotifier, _TitleMixin { - /// Octopus delegate. - /// {@nodoc} - OctopusDelegate({ - required OctopusState$Immutable initialState, - required OctopusRoute defaultRoute, - required this.routes, - List? history, - List? guards, - String? restorationScopeId = 'octopus', - List? observers, - TransitionDelegate? transitionDelegate, - NotFoundBuilder? notFound, - void Function(Object error, StackTrace stackTrace)? onError, - }) : _observer = _OctopusStateObserver(initialState, history), - _defaultRoute = defaultRoute, - _guards = guards?.toList(growable: false) ?? [], - _restorationScopeId = restorationScopeId, - _observers = observers, - _transitionDelegate = transitionDelegate ?? - (kIsWeb - ? const NoAnimationTransitionDelegate() - : const DefaultTransitionDelegate()), - _notFound = notFound, - _onError = onError { - // Subscribe to the guards. - _guardsListener = Listenable.merge(_guards)..addListener(_onGuardsNotified); - // Revalidate the initial state with the guards. - _setConfiguration(initialState); - // Clear extra storage when processing completed. - _$stateChangeQueue.addCompleteListener(_onIdleState); - // Update title & color - _updateTitle(routes[currentConfiguration.children.lastOrNull?.name]); - } - - final _OctopusStateObserver _observer; - - /// {@nodoc} - @Deprecated('Renamed to "observer".') - OctopusStateObserver get stateObserver => _observer; - - /// State observer, - /// which can be used to listen to changes in the [OctopusState]. - OctopusStateObserver get observer => _observer; - - /// Current configuration. - @override - OctopusState$Immutable get currentConfiguration => _observer.value; - - /// The restoration scope id for the navigator. - final String? _restorationScopeId; - - /// Observers for the navigator. - final List? _observers; - - /// Transition delegate. - final TransitionDelegate _transitionDelegate; - - /// Not found widget builder. - final NotFoundBuilder? _notFound; - - /// Error handler. - final void Function(Object error, StackTrace stackTrace)? _onError; - - /// Current octopus instance. - @internal - late WeakReference $controller; - +abstract base 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>(); - - T _handleErrors( - T Function() callback, [ - T Function(Object error, StackTrace stackTrace)? fallback, - ]) { - try { - return callback(); - } on Object catch (e, s) { - _onError?.call(e, s); - if (fallback == null) rethrow; - return fallback(e, s); - } - } - - @override - Widget build(BuildContext context) => InheritedOctopus( - octopus: $controller.target!, - state: _observer.value, - child: OctopusNavigator( - router: $controller.target!, - restorationScopeId: _restorationScopeId, - reportsRouteUpdateToEngine: true, - observers: [ - _modalObserver, - ...?_observers, - ], - transitionDelegate: _transitionDelegate, - pages: buildPagesFromNodes( - context, - _observer.value.children, - _defaultRoute, - ), - onPopPage: _onPopPage, - onUnknownRoute: (settings) => _onUnknownRoute(context, settings), - ), - ); - - bool _onPopPage(Route route, Object? result) => _handleErrors( - () { - if (!route.didPop(result)) return false; - { - final state = _observer.value.mutate(); - if (state.children.isEmpty) return false; - state.children.removeLast(); - setNewRoutePath(state); - } - return true; - }, - (_, __) => false, - ); - - @internal - List> buildPagesFromNodes( - BuildContext context, - List nodes, - OctopusRoute defaultRoute, - ) => - _handleErrors( - () => measureSync( - 'buildPagesFromNodes', - () { - final pages = >[]; - // Build pages - for (final node in nodes) { - try { - final Page page; - final route = routes[node.name]; - if (route == null) { - if (_notFound != null) { - page = MaterialPage( - child: _notFound.call( - context, - node.name, - node.arguments, - ), - arguments: node.arguments, - ); - } else { - _onError?.call( - Exception('Unknown route ${node.name}'), - StackTrace.current, - ); - continue; - } - } else { - 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; - // Build default page if no pages were built - return >[ - defaultRoute.pageBuilder( - context, - defaultRoute.node(), - ), - ]; - }, - arguments: kMeasureEnabled - ? { - 'nodes': nodes.map((e) => e.name).join(', ') - } - : null, - ), (error, stackTrace) { - developer.log( - 'Failed to build pages', - name: 'octopus', - error: error, - stackTrace: stackTrace, - level: 1000, - ); - final flutterError = switch (error) { - FlutterError error => error, - String message => FlutterError(message), - _ => FlutterError.fromParts( - [ - ErrorSummary('Failed to build pages'), - ErrorDescription(Error.safeToString(error)), - ], - ), - }; - return >[ - MaterialPage( - child: Scaffold( - body: SafeArea( - child: ErrorWidget.withDetails( - message: 'Failed to build pages', - error: flutterError, - ), - ), - ), - arguments: { - 'error': Error.safeToString(error), - 'stack': stackTrace.toString(), - }, - ), - ]; - }); - - @override - Future popRoute() => _handleErrors(() { - final nav = navigator; - assert(nav != null, 'Navigator is not attached to the OctopusDelegate'); - if (nav == null) return SynchronousFuture(false); - return nav.maybePop(); - }); - - Route? _onUnknownRoute( - BuildContext context, RouteSettings settings) => - _handleErrors( - () { - final widget = _notFound?.call( - context, - settings.name ?? 'unknown', - switch (settings.arguments) { - Map arguments => arguments, - _ => const {}, - }); - if (widget != null) - return MaterialPageRoute( - builder: (_) => widget, - settings: 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, - ); - - late final OctopusStateQueue _$stateChangeQueue = - OctopusStateQueue(processor: _setConfiguration); + abstract final Map routes; /// Whether the controller is currently processing a tasks. - bool get isProcessing => _$stateChangeQueue.isProcessing; + bool get isProcessing; /// Completes when processing queue is empty /// and all transactions are completed. /// This is mean controller is ready to use and in a idle state. - Future get processingCompleted => - _$stateChangeQueue.processingCompleted; - - void _onIdleState() { - if (_$stateChangeQueue.isProcessing) return; - final keys = {}; - currentConfiguration.visitChildNodes((node) { - keys.add(node.key); - return true; - }); - $NodeExtraStorage().removeEverythingExcept(keys); - } - - @override - Future setNewRoutePath(covariant OctopusState configuration) async => - // Add configuration to the queue to process it later - _$stateChangeQueue.add(configuration); - - @override - Future setInitialRoutePath(covariant OctopusState configuration) { - if (configuration.children.isEmpty) return SynchronousFuture(null); - return setNewRoutePath(configuration); - } - - @override - Future setRestoredRoutePath(covariant OctopusState configuration) { - if (configuration.children.isEmpty) return SynchronousFuture(null); - return setNewRoutePath(configuration); - } - - /// Called when the one of the guards changed. - void _onGuardsNotified() { - setNewRoutePath( - _observer.value.mutate()..intention = OctopusStateIntention.replace); - } - - /// DO NOT USE THIS METHOD DIRECTLY. - /// Use [setNewRoutePath] instead. - /// Used for OctopusStateQueue. - /// - /// {@nodoc} - @protected - @nonVirtual - Future _setConfiguration(OctopusState configuration) => _handleErrors( - () => measureAsync>( - '_setConfiguration', - () async { - // Do nothing: - if (configuration.intention == OctopusStateIntention.cancel) return; - - // Create a mutable copy of the configuration - // to allow changing it in the guards - var newConfiguration = configuration is OctopusState$Mutable - ? configuration - : configuration.mutate(); - - if (_guards.isNotEmpty) { - // Get the history of the states - final history = _observer.history; - - // Unsubscribe from the guards to avoid infinite loop - _guardsListener.removeListener(_onGuardsNotified); - final context = {}; - for (final guard in _guards) { - try { - // Call the guard and get the new state - final result = - await guard(history, newConfiguration, context); - newConfiguration = result.mutate(); - // Cancel navigation on [OctopusStateIntention.cancel] - if (newConfiguration.intention == - OctopusStateIntention.cancel) return; - } 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 the guard failed - } - } - // Resubscribe to the guards - _guardsListener.addListener(_onGuardsNotified); - } - - // Validate configuration - if (newConfiguration.children.isEmpty) return; - - // Normalize configuration - final result = StateUtil.normalize(newConfiguration); - - if (_observer._changeState(result)) { - _updateTitle(routes[result.children.lastOrNull?.name]); - notifyListeners(); // Notify listeners if the state changed - } - }, - ), - (_, __) => SynchronousFuture(null), - ); - - @override - void dispose() { - _guardsListener.removeListener(_onGuardsNotified); - _$stateChangeQueue - ..removeCompleteListener(_onIdleState) - ..close(); - super.dispose(); - } -} - -mixin _TitleMixin { - String? _$lastTitle; - Color? _$lastColor; - // Update title & color - void _updateTitle(OctopusRoute? route) { - final title = route?.title; - final color = route?.color; - if (title == _$lastTitle && _$lastColor == color) return; - if (kIsWeb && title == null) return; - if (!kIsWeb && (title == null || color == null)) return; - SystemChrome.setApplicationSwitcherDescription( - ApplicationSwitcherDescription( - label: _$lastTitle = title, - primaryColor: (_$lastColor = color)?.value, - ), - ).ignore(); - } -} - -/// Octopus state observer. -abstract interface class OctopusStateObserver - implements ValueListenable { - /// Max history length. - static const int maxHistoryLength = 10000; - - /// Current immutable state. - @override - OctopusState$Immutable get value; - - /// History of the states. - List get history; -} - -final class _OctopusStateObserver - with ChangeNotifier - implements OctopusStateObserver { - _OctopusStateObserver(OctopusState$Immutable initialState, - [List? history]) - : _value = OctopusState$Immutable.from(initialState), - _history = history?.toSet().toList() ?? [] { - // Add the initial state to the history. - if (_history.isEmpty || _history.last.state != initialState) { - _history.add( - OctopusHistoryEntry( - state: initialState, - timestamp: DateTime.now(), - ), - ); - } - _history.sort(); - } - - @protected - @nonVirtual - OctopusState$Immutable _value; - - @protected - @nonVirtual - final List _history; - - @override - List get history => - UnmodifiableListView(_history); - - @override - OctopusState$Immutable get value => _value; - - @nonVirtual - bool _changeState(OctopusState state) { - if (state.children.isEmpty) return false; - if (state.intention == OctopusStateIntention.cancel) return false; - final newValue = OctopusState$Immutable.from(state); - if (_value == newValue) return false; - _value = newValue; - late final historyEntry = OctopusHistoryEntry( - state: newValue, - timestamp: DateTime.now(), - ); - switch (_value.intention) { - case OctopusStateIntention.auto: - case OctopusStateIntention.navigate: - case OctopusStateIntention.replace when _history.isEmpty: - _history.add(historyEntry); - case OctopusStateIntention.replace: - _history.last = historyEntry; - case OctopusStateIntention.neglect: - case OctopusStateIntention.cancel: - break; - } - if (_history.length > OctopusStateObserver.maxHistoryLength) - _history.removeAt(0); - notifyListeners(); - return true; - } -} - -/// {@template history_entry} -/// Octopus history entry. -/// {@endtemplate} -@immutable -final class OctopusHistoryEntry implements Comparable { - /// {@macro history_entry} - OctopusHistoryEntry({ - required this.state, - DateTime? timestamp, - }) : timestamp = timestamp ?? DateTime.now(); - - /// Create an entry from json. - /// - /// {@macro history_entry} - factory OctopusHistoryEntry.fromJson(Map json) { - if (json - case { - 'timestamp': String timestamp, - 'state': Map state, - }) { - return OctopusHistoryEntry( - state: OctopusState.fromJson(state).freeze(), - timestamp: DateTime.parse(timestamp), - ); - } else { - throw const FormatException('Invalid json'); - } - } - - /// The state of the entry. - final OctopusState$Immutable state; - - /// The timestamp of the entry. - final DateTime timestamp; - - @override - int compareTo(covariant OctopusHistoryEntry other) => - timestamp.compareTo(other.timestamp); - - /// Convert the entry to json. - Map toJson() => { - 'timestamp': timestamp.toIso8601String(), - 'state': state.toJson(), - }; - - @override - late final int hashCode = state.hashCode ^ timestamp.hashCode; + Future get processingCompleted; - @override - bool operator ==(Object other) => - identical(this, other) || - other is OctopusHistoryEntry && - timestamp == other.timestamp && - state == other.state; + /// Build pages from [OctopusNode]s. + List> buildPages(BuildContext context, List nodes); } diff --git a/lib/src/controller/navigator/controller.dart b/lib/src/controller/navigator/controller.dart new file mode 100644 index 0000000..eabe431 --- /dev/null +++ b/lib/src/controller/navigator/controller.dart @@ -0,0 +1,312 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:octopus/src/controller/config.dart'; +import 'package:octopus/src/controller/controller.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/controller/navigator/delegate.dart'; +import 'package:octopus/src/controller/navigator/observer.dart'; +import 'package:octopus/src/controller/observer.dart'; +import 'package:octopus/src/controller/singleton.dart'; +import 'package:octopus/src/controller/typedefs.dart'; +import 'package:octopus/src/state/name_regexp.dart'; +import 'package:octopus/src/state/state.dart'; +import 'package:octopus/src/util/state_util.dart'; + +/// {@nodoc} +@internal +final class Octopus$NavigatorImpl implements Octopus { + /// {@nodoc} + factory Octopus$NavigatorImpl({ + required Iterable routes, + OctopusRoute? defaultRoute, + List? guards, + OctopusState? initialState, + List? history, + Codec? codec, + String? restorationScopeId = 'octopus', + List? observers, + TransitionDelegate? transitionDelegate, + NotFoundBuilder? notFound, + void Function(Object error, StackTrace stackTrace)? onError, + }) { + final list = List.of(routes, growable: false); + assert(list.isNotEmpty, 'Routes list should contain at least one route'); + defaultRoute ??= list.firstOrNull; + if (defaultRoute == null) { + final error = StateError('Routes list should contain at least one route'); + onError?.call(error, StackTrace.current); + throw error; + } + assert( + list.every((e) => e.name.length > 1 && !e.name.startsWith('/')), + 'Route name should not start with a "/" ' + 'and should be at least 2 characters long', + ); + assert( + list.every((e) => e.name.contains($nameRegExp)), + 'Route name should contain only latin letters, numbers, and hyphens', + ); + assert( + list.map((e) => e.name).toSet().length == list.length, + 'Routes list should not contain duplicate names', + ); + final routeInformationProvider = OctopusInformationProvider(); + final backButtonDispatcher = RootBackButtonDispatcher(); + final routeInformationParser = OctopusInformationParser(codec: codec); + final routesTable = Map.unmodifiable( + { + for (final route in list) route.name: route, + }, + ); + final observer = OctopusStateObserver$NavigatorImpl( + initialState?.freeze() ?? + OctopusState$Immutable( + children: [defaultRoute.node()], + arguments: const {}, + intention: OctopusStateIntention.neglect, + ), + history, + ); + final routerDelegate = OctopusDelegate$NavigatorImpl( + observer: observer, + routes: routesTable, + defaultRoute: defaultRoute, + guards: guards, + restorationScopeId: restorationScopeId, + observers: observers, + transitionDelegate: transitionDelegate, + notFound: notFound, + onError: onError, + ); + final controller = Octopus$NavigatorImpl._( + routes: routesTable, + routeInformationProvider: routeInformationProvider, + routeInformationParser: routeInformationParser, + routerDelegate: routerDelegate, + backButtonDispatcher: backButtonDispatcher, + observer: observer, + ); + routerDelegate.$octopus = WeakReference(controller); + return controller; + } + + Octopus$NavigatorImpl._({ + required Map routes, + required OctopusDelegate$NavigatorImpl routerDelegate, + required OctopusInformationProvider routeInformationProvider, + required OctopusInformationParser routeInformationParser, + required BackButtonDispatcher backButtonDispatcher, + required OctopusStateObserver observer, + }) : config = OctopusConfig( + routes: routes, + routeInformationProvider: routeInformationProvider, + routeInformationParser: routeInformationParser, + routerDelegate: routerDelegate, + backButtonDispatcher: backButtonDispatcher, + observer: observer, + ) { + $octopusSingletonInstance = this; + } + + @override + final OctopusConfig config; + + @override + OctopusStateObserver get stateObserver => observer; + + @override + OctopusStateObserver get observer => config.observer; + + @override + OctopusState$Immutable get state => stateObserver.value; + + @override + List get history => stateObserver.history; + + @override + bool get isIdle => !isProcessing; + + @override + bool get isProcessing => config.routerDelegate.isProcessing; + + @override + Future get processingCompleted => + config.routerDelegate.processingCompleted; + @override + OctopusRoute? getRouteByName(String name) => config.routes[name]; + + @override + Future setState( + OctopusState Function(OctopusState$Mutable state) change) => + config.routerDelegate.setNewRoutePath( + change(state.mutate()..intention = OctopusStateIntention.auto)); + + @override + Future navigate(String location) => + config.routerDelegate.setNewRoutePath(StateUtil.decodeLocation(location)); + + @override + Future pop() { + OctopusNode? result; + return setState((state) { + if (state.children.length < 2) { + SystemNavigator.pop().ignore(); + return state; + } + result = state.removeLast(); + return state; + }).then((_) => result); + } + + @override + Future maybePop() { + OctopusNode? result; + return setState((state) { + if (state.children.length < 2) return state; + result = state.removeLast(); + return state; + }).then((_) => result); + } + + @override + Future popAll() => setState((state) { + final first = state.children.firstOrNull; + if (first == null) return state; + return OctopusState.single(first, arguments: state.arguments); + }); + + @override + Future> popUntil( + bool Function(OctopusNode$Mutable node) predicate) { + final result = []; + return setState((state) { + result.addAll(state.removeUntil(predicate)); + return state; + }).then((_) => result); + } + + @override + Future push(OctopusRoute route, {Map? arguments}) => + setState((state) => state..add(route.node(arguments: arguments))); + + @override + Future pushNamed(String name, {Map? arguments}) { + final route = getRouteByName(name); + if (route == null) { + assert(false, 'Route with name "$name" not found'); + return Future.value(); + } else { + return push(route, arguments: arguments); + } + } + + @override + Future pushAll( + List<({OctopusRoute route, Map? arguments})> + routes) => + setState((state) => state + ..addAll( + [for (final e in routes) e.route.node(arguments: e.arguments)])); + + @override + Future pushAllNamed( + List<({String name, Map? arguments})> routes) { + final nodes = []; + final table = config.routes; + for (final e in routes) { + final route = table[e.name]; + if (route == null) { + assert(false, 'Route with name "${e.name}" not found'); + } else { + nodes.add(route.node(arguments: e.arguments)); + } + } + if (nodes.isEmpty) return Future.value(); + return setState((state) => state..addAll(nodes)); + } + + @override + Future upsertLast( + OctopusRoute route, { + Map? arguments, + }) { + late OctopusNode result; + return setState((state) { + result = state.upsertLast(route.node(arguments: arguments)); + return state; + }).then((_) => result); + } + + @override + Future upsertLastNamed( + String name, { + Map? arguments, + }) { + final route = getRouteByName(name); + if (route == null) { + throw StateError('Route with name "$name" not found'); + } else { + return upsertLast(route, arguments: arguments); + } + } + + @override + Future replaceAll( + OctopusNode Function(OctopusNode$Mutable) fn, { + bool recursive = true, + }) => + setState((state) => state..replaceAll(fn, recursive: recursive)); + + @override + Future setArguments(void Function(Map args) change) => + setState((state) { + change(state.arguments); + return state; + }); + + Completer? _txnCompleter; + final Queue<(OctopusState Function(OctopusState$Mutable), int)> _txnQueue = + Queue<(OctopusState Function(OctopusState$Mutable), int)>(); + + @override + Future transaction( + OctopusState Function(OctopusState$Mutable state) change, { + int? priority, + }) async { + Completer completer; + if (_txnCompleter == null || _txnCompleter!.isCompleted) { + completer = _txnCompleter = Completer.sync(); + Future.delayed(Duration.zero, () { + var mutableState = state.mutate() + ..intention = OctopusStateIntention.auto; + final list = _txnQueue.toList(growable: false) + ..sort((a, b) => b.$2.compareTo(a.$2)); + _txnQueue.clear(); + for (final fn in list) { + try { + mutableState = switch (fn.$1(mutableState)) { + OctopusState$Mutable state => state, + OctopusState$Immutable state => state.mutate(), + }; + } on Object {/* ignore */} + } + setState((_) => mutableState); + if (completer.isCompleted) return; + completer.complete(); + }); + } else { + completer = _txnCompleter!; + } + priority ??= _txnQueue.fold(0, (p, e) => math.min(p, e.$2)) - 1; + _txnQueue.add((change, priority)); + return completer.future; + } +} diff --git a/lib/src/controller/navigator/delegate.dart b/lib/src/controller/navigator/delegate.dart new file mode 100644 index 0000000..a190e68 --- /dev/null +++ b/lib/src/controller/navigator/delegate.dart @@ -0,0 +1,434 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; +import 'package:octopus/src/controller/controller.dart'; +import 'package:octopus/src/controller/delegate.dart'; +import 'package:octopus/src/controller/guard.dart'; +import 'package:octopus/src/controller/navigator/observer.dart'; +import 'package:octopus/src/controller/observer.dart'; +import 'package:octopus/src/controller/state_queue.dart'; +import 'package:octopus/src/controller/typedefs.dart'; +import 'package:octopus/src/state/node_extra_storage.dart'; +import 'package:octopus/src/state/state.dart'; +import 'package:octopus/src/util/logs.dart'; +import 'package:octopus/src/util/state_util.dart'; +import 'package:octopus/src/widget/inherited_octopus.dart'; +import 'package:octopus/src/widget/navigator.dart'; +import 'package:octopus/src/widget/no_animation.dart'; + +/// Octopus delegate. +/// {@nodoc} +@internal +final class OctopusDelegate$NavigatorImpl extends OctopusDelegate + with ChangeNotifier, _TitleMixin { + /// Octopus delegate. + /// {@nodoc} + OctopusDelegate$NavigatorImpl({ + required OctopusRoute defaultRoute, + required this.routes, + required OctopusStateObserver$NavigatorImpl observer, + List? guards, + String? restorationScopeId = 'octopus', + List? observers, + TransitionDelegate? transitionDelegate, + NotFoundBuilder? notFound, + void Function(Object error, StackTrace stackTrace)? onError, + }) : _observer = observer, + _defaultRoute = defaultRoute, + _guards = guards?.toList(growable: false) ?? [], + _restorationScopeId = restorationScopeId, + _observers = observers, + _transitionDelegate = transitionDelegate ?? + (kIsWeb + ? const NoAnimationTransitionDelegate() + : const DefaultTransitionDelegate()), + _notFound = notFound, + _onError = onError { + // Subscribe to the guards. + _guardsListener = Listenable.merge(_guards)..addListener(_onGuardsNotified); + // Revalidate the initial state with the guards. + _setConfiguration(observer.value); + // Clear extra storage when processing completed. + _$stateChangeQueue.addCompleteListener(_onIdleState); + // Update title & color + _updateTitle(routes[currentConfiguration.children.lastOrNull?.name]); + } + + final OctopusStateObserver$NavigatorImpl _observer; + + /// {@nodoc} + @Deprecated('Renamed to "observer".') + OctopusStateObserver get stateObserver => _observer; + + /// State observer, + /// which can be used to listen to changes in the [OctopusState]. + OctopusStateObserver get observer => _observer; + + /// Current configuration. + @override + OctopusState$Immutable get currentConfiguration => _observer.value; + + /// The restoration scope id for the navigator. + final String? _restorationScopeId; + + /// Observers for the navigator. + final List? _observers; + + /// Transition delegate. + final TransitionDelegate _transitionDelegate; + + /// Not found widget builder. + final NotFoundBuilder? _notFound; + + /// Error handler. + final void Function(Object error, StackTrace stackTrace)? _onError; + + /// Current octopus instance. + @internal + late WeakReference $octopus; + + /// Routes hash table. + @override + 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>(); + + T _handleErrors( + T Function() callback, [ + T Function(Object error, StackTrace stackTrace)? fallback, + ]) { + try { + return callback(); + } on Object catch (e, s) { + _onError?.call(e, s); + if (fallback == null) rethrow; + return fallback(e, s); + } + } + + @override + Widget build(BuildContext context) { + final pages = buildPages(context, _observer.value.children); + if (pages.isEmpty) + pages.add( + _defaultRoute.pageBuilder( + context, + _defaultRoute.node(), + ), + ); + return InheritedOctopus( + octopus: $octopus.target!, + state: _observer.value, + child: OctopusNavigator( + router: $octopus.target!, + restorationScopeId: _restorationScopeId, + reportsRouteUpdateToEngine: true, + observers: [ + _modalObserver, + ...?_observers, + ], + transitionDelegate: _transitionDelegate, + pages: pages, + onPopPage: _onPopPage, + onUnknownRoute: (settings) => _onUnknownRoute(context, settings), + ), + ); + } + + bool _onPopPage(Route route, Object? result) => _handleErrors( + () { + if (!route.didPop(result)) return false; + { + final state = _observer.value.mutate(); + if (state.children.isEmpty) return false; + state.children.removeLast(); + setNewRoutePath(state); + } + return true; + }, + (_, __) => false, + ); + + @override + @internal + List> buildPages( + BuildContext context, List nodes) => + _handleErrors( + () => measureSync( + 'buildPagesFromNodes', + () { + final pages = >[]; + // Build pages + for (final node in nodes) { + try { + final Page page; + final route = routes[node.name]; + if (route == null) { + if (_notFound != null) { + page = MaterialPage( + child: _notFound.call( + context, + node.name, + node.arguments, + ), + arguments: node.arguments, + ); + } else { + _onError?.call( + Exception('Unknown route ${node.name}'), + StackTrace.current, + ); + continue; + } + } else { + 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; + return >[]; + }, + arguments: kMeasureEnabled + ? { + 'nodes': nodes.map((e) => e.name).join(', ') + } + : null, + ), + (error, stackTrace) { + developer.log( + 'Failed to build pages', + name: 'octopus', + error: error, + stackTrace: stackTrace, + level: 1000, + ); + final flutterError = switch (error) { + FlutterError error => error, + String message => FlutterError(message), + _ => FlutterError.fromParts( + [ + ErrorSummary('Failed to build pages'), + ErrorDescription(Error.safeToString(error)), + ], + ), + }; + return >[ + MaterialPage( + child: Scaffold( + body: SafeArea( + child: ErrorWidget.withDetails( + message: 'Failed to build pages', + error: flutterError, + ), + ), + ), + arguments: { + 'error': Error.safeToString(error), + 'stack': stackTrace.toString(), + }, + ), + ]; + }, + ); + + @override + Future popRoute() => _handleErrors(() { + final nav = navigator; + assert(nav != null, 'Navigator is not attached to the OctopusDelegate'); + if (nav == null) return SynchronousFuture(false); + return nav.maybePop(); + }); + + Route? _onUnknownRoute( + BuildContext context, RouteSettings settings) => + _handleErrors( + () { + final widget = _notFound?.call( + context, + settings.name ?? 'unknown', + switch (settings.arguments) { + Map arguments => arguments, + _ => const {}, + }); + if (widget != null) + return MaterialPageRoute( + builder: (_) => widget, + settings: 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, + ); + + late final OctopusStateQueue _$stateChangeQueue = + OctopusStateQueue(processor: _setConfiguration); + + @override + bool get isProcessing => _$stateChangeQueue.isProcessing; + + @override + Future get processingCompleted => + _$stateChangeQueue.processingCompleted; + + void _onIdleState() { + if (_$stateChangeQueue.isProcessing) return; + final keys = {}; + currentConfiguration.visitChildNodes((node) { + keys.add(node.key); + return true; + }); + $NodeExtraStorage().removeEverythingExcept(keys); + } + + @override + Future setNewRoutePath(covariant OctopusState configuration) async => + // Add configuration to the queue to process it later + _$stateChangeQueue.add(configuration); + + @override + Future setInitialRoutePath(covariant OctopusState configuration) { + if (configuration.children.isEmpty) return SynchronousFuture(null); + return setNewRoutePath(configuration); + } + + @override + Future setRestoredRoutePath(covariant OctopusState configuration) { + if (configuration.children.isEmpty) return SynchronousFuture(null); + return setNewRoutePath(configuration); + } + + /// Called when the one of the guards changed. + void _onGuardsNotified() { + setNewRoutePath( + _observer.value.mutate()..intention = OctopusStateIntention.replace); + } + + /// DO NOT USE THIS METHOD DIRECTLY. + /// Use [setNewRoutePath] instead. + /// Used for OctopusStateQueue. + /// + /// {@nodoc} + @protected + @nonVirtual + Future _setConfiguration(OctopusState configuration) => _handleErrors( + () => measureAsync>( + '_setConfiguration', + () async { + // Do nothing: + if (configuration.intention == OctopusStateIntention.cancel) return; + + // Create a mutable copy of the configuration + // to allow changing it in the guards + var newConfiguration = configuration is OctopusState$Mutable + ? configuration + : configuration.mutate(); + + if (_guards.isNotEmpty) { + // Get the history of the states + final history = _observer.history; + + // Unsubscribe from the guards to avoid infinite loop + _guardsListener.removeListener(_onGuardsNotified); + final context = {}; + for (final guard in _guards) { + try { + // Call the guard and get the new state + final result = + await guard(history, newConfiguration, context); + newConfiguration = result.mutate(); + // Cancel navigation on [OctopusStateIntention.cancel] + if (newConfiguration.intention == + OctopusStateIntention.cancel) return; + } 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 the guard failed + } + } + // Resubscribe to the guards + _guardsListener.addListener(_onGuardsNotified); + } + + // Validate configuration + if (newConfiguration.children.isEmpty) return; + + // Normalize configuration + final result = StateUtil.normalize(newConfiguration); + + if (_observer.changeState(result)) { + _updateTitle(routes[result.children.lastOrNull?.name]); + notifyListeners(); // Notify listeners if the state changed + } + }, + ), + (_, __) => SynchronousFuture(null), + ); + + @override + void dispose() { + _guardsListener.removeListener(_onGuardsNotified); + _$stateChangeQueue + ..removeCompleteListener(_onIdleState) + ..close(); + super.dispose(); + } +} + +mixin _TitleMixin { + String? _$lastTitle; + Color? _$lastColor; + // Update title & color + void _updateTitle(OctopusRoute? route) { + final title = route?.title; + final color = route?.color; + if (title == _$lastTitle && _$lastColor == color) return; + if (kIsWeb && title == null) return; + if (!kIsWeb && (title == null || color == null)) return; + SystemChrome.setApplicationSwitcherDescription( + ApplicationSwitcherDescription( + label: _$lastTitle = title, + primaryColor: (_$lastColor = color)?.value, + ), + ).ignore(); + } +} diff --git a/lib/src/controller/navigator/observer.dart b/lib/src/controller/navigator/observer.dart new file mode 100644 index 0000000..c8f7f84 --- /dev/null +++ b/lib/src/controller/navigator/observer.dart @@ -0,0 +1,78 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; +import 'package:meta/meta.dart'; +import 'package:octopus/src/controller/observer.dart'; +import 'package:octopus/src/state/state.dart'; + +@internal +final class OctopusStateObserver$NavigatorImpl + with ChangeNotifier + implements OctopusStateObserver { + OctopusStateObserver$NavigatorImpl(OctopusState$Immutable initialState, + [List? history]) + : _value = OctopusState$Immutable.from(initialState), + _history = history?.toSet().toList() ?? [] { + // Add the initial state to the history. + if (_history.isEmpty || _history.last.state != initialState) { + _history.add( + OctopusHistoryEntry( + state: initialState, + timestamp: DateTime.now(), + ), + ); + } + _history.sort(); + } + + @protected + @nonVirtual + OctopusState$Immutable _value; + + @protected + @nonVirtual + final List _history; + + @override + List get history => + UnmodifiableListView(_history); + + @override + void setHistory(Iterable history) { + _history + ..clear() + ..addAll(history) + ..sort(); + } + + @override + OctopusState$Immutable get value => _value; + + @internal + bool changeState(OctopusState state) { + if (state.children.isEmpty) return false; + if (state.intention == OctopusStateIntention.cancel) return false; + final newValue = OctopusState$Immutable.from(state); + if (_value == newValue) return false; + _value = newValue; + late final historyEntry = OctopusHistoryEntry( + state: newValue, + timestamp: DateTime.now(), + ); + switch (_value.intention) { + case OctopusStateIntention.auto: + case OctopusStateIntention.navigate: + case OctopusStateIntention.replace when _history.isEmpty: + _history.add(historyEntry); + case OctopusStateIntention.replace: + _history.last = historyEntry; + case OctopusStateIntention.neglect: + case OctopusStateIntention.cancel: + break; + } + if (_history.length > OctopusStateObserver.maxHistoryLength) + _history.removeAt(0); + notifyListeners(); + return true; + } +} diff --git a/lib/src/controller/observer.dart b/lib/src/controller/observer.dart new file mode 100644 index 0000000..2d2a336 --- /dev/null +++ b/lib/src/controller/observer.dart @@ -0,0 +1,76 @@ +import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:meta/meta.dart'; +import 'package:octopus/src/state/state.dart'; + +/// Octopus state observer. +abstract interface class OctopusStateObserver + implements ValueListenable { + /// Max history length. + static const int maxHistoryLength = 10000; + + /// Current immutable state. + @override + OctopusState$Immutable get value; + + /// History of the states. + List get history; + + /// Set history. + void setHistory(Iterable history); +} + +/// {@template history_entry} +/// Octopus history entry. +/// {@endtemplate} +@immutable +final class OctopusHistoryEntry implements Comparable { + /// {@macro history_entry} + OctopusHistoryEntry({ + required this.state, + DateTime? timestamp, + }) : timestamp = timestamp ?? DateTime.now(); + + /// Create an entry from json. + /// + /// {@macro history_entry} + factory OctopusHistoryEntry.fromJson(Map json) { + if (json + case { + 'timestamp': String timestamp, + 'state': Map state, + }) { + return OctopusHistoryEntry( + state: OctopusState.fromJson(state).freeze(), + timestamp: DateTime.parse(timestamp), + ); + } else { + throw const FormatException('Invalid json'); + } + } + + /// The state of the entry. + final OctopusState$Immutable state; + + /// The timestamp of the entry. + final DateTime timestamp; + + @override + int compareTo(covariant OctopusHistoryEntry other) => + timestamp.compareTo(other.timestamp); + + /// Convert the entry to json. + Map toJson() => { + 'timestamp': timestamp.toIso8601String(), + 'state': state.toJson(), + }; + + @override + late final int hashCode = state.hashCode ^ timestamp.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OctopusHistoryEntry && + timestamp == other.timestamp && + state == other.state; +} diff --git a/lib/src/controller/octopus.dart b/lib/src/controller/octopus.dart deleted file mode 100644 index 5477c60..0000000 --- a/lib/src/controller/octopus.dart +++ /dev/null @@ -1,494 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:convert'; -import 'dart:math' as math; - -import 'package:flutter/services.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/util/state_util.dart'; -import 'package:octopus/src/widget/inherited_octopus.dart'; - -/// {@template octopus} -/// The main class of the package. -/// Router configuration is provided by the [routes] parameter. -/// {@endtemplate} -abstract base class Octopus { - /// {@macro octopus} - factory Octopus({ - required List routes, - OctopusRoute? defaultRoute, - List? guards, - OctopusState? initialState, - List? history, - Codec? codec, - String? restorationScopeId, - List? observers, - TransitionDelegate? transitionDelegate, - NotFoundBuilder? notFound, - void Function(Object error, StackTrace stackTrace)? onError, - }) = _OctopusImpl; - - Octopus._({required this.config}) { - _$octopusSingletonInstance = this; - } - - /// Receives the [Octopus] instance from the elements tree. - static Octopus? maybeOf(BuildContext context) => - InheritedOctopus.maybeOf(context, listen: false)?.octopus; - - /// Receives the [Octopus] instance from the elements tree. - static Octopus of(BuildContext context) => - InheritedOctopus.of(context, listen: false).octopus; - - /// Receives the current [OctopusState] instance from the elements tree. - static OctopusState$Immutable stateOf( - BuildContext context, { - bool listen = true, - }) => - InheritedOctopus.of(context, listen: true).state; - - /// Receives the last initializated [Octopus] instance. - static Octopus get instance => - _$octopusSingletonInstance ?? _throwOctopusNotInitialized(); - static Octopus? _$octopusSingletonInstance; - static Never _throwOctopusNotInitialized() => - throw Exception('Octopus is not initialized yet.'); - - /// A convenient bundle to configure a [Router] widget. - final OctopusConfig config; - - /// {@nodoc} - @Deprecated('Renamed to "observer".') - OctopusStateObserver get stateObserver; - - /// State observer, - /// which can be used to listen to changes in the [OctopusState]. - OctopusStateObserver get observer; - - /// Current state. - OctopusState$Immutable get state; - - /// History of the [OctopusState] states. - List get history; - - /// Completes when processing queue is empty - /// and all transactions are completed. - /// This is mean controller is ready to use and in a idle state. - Future get processingCompleted; - - /// Whether the controller is currently processing a tasks. - bool get isProcessing; - - /// Whether the controller is currently idle. - bool get isIdle; - - /// Set new state and rebuild the navigation tree if needed. - /// - /// Better to use [transaction] method to change multiple states - /// at once synchronously at the same time and merge changes into transaction. - Future setState( - OctopusState Function(OctopusState$Mutable state) change); - - /// Navigate to the specified location. - Future navigate(String location); - - /// Execute a synchronous transaction. - /// For example you can use it to change multiple states at once and - /// combine them into one change. - /// - /// [change] is a function that takes the current state as an argument - /// and returns a new state. - /// [priority] is used to determine the order of execution of transactions. - /// The higher the priority, the earlier the transaction will be executed. - /// If the priority is not specified, the transaction will be executed - /// in the order in which it was added. - Future transaction( - OctopusState Function(OctopusState$Mutable state) change, { - int? priority, - }); - - /// Push a new top route to the navigation stack - /// with the specified [arguments]. - Future push(OctopusRoute route, {Map? arguments}); - - /// Push a new top route to the navigation stack - /// with the specified [arguments]. - Future pushNamed( - String name, { - Map? arguments, - }); - - /// Push multiple routes to the navigation stack. - Future pushAll( - List<({OctopusRoute route, Map? arguments})> routes); - - /// Push multiple routes to the navigation stack. - Future pushAllNamed( - List<({String name, Map? arguments})> routes, - ); - - /// Mutate all nodes with a new one. From leaf to root. - Future replaceAll( - OctopusNode Function(OctopusNode$Mutable) fn, { - bool recursive = true, - }); - - /// Replace the last top route in the navigation stack with a new one. - Future upsertLast( - OctopusRoute route, { - Map? arguments, - }); - - /// Replace the last top route in the navigation stack with a new one. - Future upsertLastNamed( - String name, { - Map? arguments, - }); - - /// Pop a one of the top routes from the navigation stack. - /// If the stack contains only one route, close the application. - Future pop(); - - /// Pop a one of the top routes from the navigation stack. - /// If the stack contains only one route, nothing will happen. - Future maybePop(); - - /// Pop all except the first route from the navigation stack. - /// If the stack contains only one route, nothing will happen. - /// Usefull to go back to the "home" route. - Future popAll(); - - /// Pop all routes from the navigation stack until the predicate is true. - /// If the test is not satisfied, - /// the node is not removed and the walk is stopped. - /// [true] - remove node - /// [false] - stop walk and keep node - Future> popUntil(bool Function(OctopusNode node) predicate); - - /// Get a route by name. - OctopusRoute? getRouteByName(String name); - - /// Update state arguments - Future setArguments(void Function(Map args) change); -} - -/// {@nodoc} -final class _OctopusImpl extends Octopus - with _OctopusDelegateOwner, _OctopusMethodsMixin, _OctopusTransactionMixin { - /// {@nodoc} - factory _OctopusImpl({ - required List routes, - OctopusRoute? defaultRoute, - List? guards, - OctopusState? initialState, - List? history, - Codec? codec, - String? restorationScopeId = 'octopus', - List? observers, - TransitionDelegate? transitionDelegate, - NotFoundBuilder? notFound, - void Function(Object error, StackTrace stackTrace)? onError, - }) { - assert(routes.isNotEmpty, 'Routes list should contain at least one route'); - final list = List.of(routes); - defaultRoute ??= list.firstOrNull; - if (defaultRoute == null) { - final error = StateError('Routes list should contain at least one route'); - onError?.call(error, StackTrace.current); - throw error; - } - assert( - list.map((e) => e.name).toSet().length == list.length, - 'Routes list should not contain duplicate names', - ); - final routeInformationProvider = OctopusInformationProvider(); - final backButtonDispatcher = RootBackButtonDispatcher(); - final routeInformationParser = OctopusInformationParser(codec: codec); - final routesTable = Map.unmodifiable( - { - for (final route in routes) route.name: route, - }, - ); - final routerDelegate = OctopusDelegate( - initialState: initialState?.freeze() ?? - OctopusState$Immutable( - children: [defaultRoute.node()], - arguments: const {}, - intention: OctopusStateIntention.neglect, - ), - history: history, - routes: routesTable, - defaultRoute: defaultRoute, - guards: guards, - restorationScopeId: restorationScopeId, - observers: observers, - transitionDelegate: transitionDelegate, - notFound: notFound, - onError: onError, - ); - final controller = _OctopusImpl._( - routes: routesTable, - routeInformationProvider: routeInformationProvider, - routeInformationParser: routeInformationParser, - routerDelegate: routerDelegate, - backButtonDispatcher: backButtonDispatcher, - ); - routerDelegate.$controller = WeakReference(controller); - return controller; - } - - _OctopusImpl._({ - required Map routes, - required OctopusDelegate routerDelegate, - required OctopusInformationProvider routeInformationProvider, - required OctopusInformationParser routeInformationParser, - required BackButtonDispatcher backButtonDispatcher, - }) : super._( - config: OctopusConfig( - routes: routes, - routeInformationProvider: routeInformationProvider, - routeInformationParser: routeInformationParser, - routerDelegate: routerDelegate, - backButtonDispatcher: backButtonDispatcher, - ), - ); - - @override - OctopusStateObserver get stateObserver => observer; - - @override - OctopusStateObserver get observer => config.routerDelegate.observer; - - @override - OctopusState$Immutable get state => stateObserver.value; - - @override - List get history => stateObserver.history; - - @override - bool get isIdle => !isProcessing; - - @override - bool get isProcessing => config.routerDelegate.isProcessing; - - @override - Future get processingCompleted => - config.routerDelegate.processingCompleted; -} - -base mixin _OctopusDelegateOwner on Octopus { - @override - abstract final OctopusStateObserver stateObserver; -} - -base mixin _OctopusMethodsMixin on Octopus { - @override - OctopusRoute? getRouteByName(String name) => config.routes[name]; - - @override - Future setState( - OctopusState Function(OctopusState$Mutable state) change) => - config.routerDelegate.setNewRoutePath( - change(state.mutate()..intention = OctopusStateIntention.auto)); - - @override - Future navigate(String location) => - config.routerDelegate.setNewRoutePath(StateUtil.decodeLocation(location)); - - @override - Future pop() { - OctopusNode? result; - return setState((state) { - if (state.children.length < 2) { - SystemNavigator.pop().ignore(); - return state; - } - result = state.removeLast(); - return state; - }).then((_) => result); - } - - @override - Future maybePop() { - OctopusNode? result; - return setState((state) { - if (state.children.length < 2) return state; - result = state.removeLast(); - return state; - }).then((_) => result); - } - - @override - Future popAll() => setState((state) { - final first = state.children.firstOrNull; - if (first == null) return state; - return OctopusState.single(first, arguments: state.arguments); - }); - - @override - Future> popUntil( - bool Function(OctopusNode$Mutable node) predicate) { - final result = []; - return setState((state) { - result.addAll(state.removeUntil(predicate)); - return state; - }).then((_) => result); - } - - @override - Future push(OctopusRoute route, {Map? arguments}) => - setState((state) => state..add(route.node(arguments: arguments))); - - @override - Future pushNamed(String name, {Map? arguments}) { - final route = getRouteByName(name); - if (route == null) { - assert(false, 'Route with name "$name" not found'); - return Future.value(); - } else { - return push(route, arguments: arguments); - } - } - - @override - Future pushAll( - List<({OctopusRoute route, Map? arguments})> - routes) => - setState((state) => state - ..addAll( - [for (final e in routes) e.route.node(arguments: e.arguments)])); - - @override - Future pushAllNamed( - List<({String name, Map? arguments})> routes) { - final nodes = []; - final table = config.routerDelegate.routes; - for (final e in routes) { - final route = table[e.name]; - if (route == null) { - assert(false, 'Route with name "${e.name}" not found'); - } else { - nodes.add(route.node(arguments: e.arguments)); - } - } - if (nodes.isEmpty) return Future.value(); - return setState((state) => state..addAll(nodes)); - } - - @override - Future upsertLast( - OctopusRoute route, { - Map? arguments, - }) { - late OctopusNode result; - return setState((state) { - result = state.upsertLast(route.node(arguments: arguments)); - return state; - }).then((_) => result); - } - - @override - Future upsertLastNamed( - String name, { - Map? arguments, - }) { - final route = getRouteByName(name); - if (route == null) { - throw StateError('Route with name "$name" not found'); - } else { - return upsertLast(route, arguments: arguments); - } - } - - @override - Future replaceAll( - OctopusNode Function(OctopusNode$Mutable) fn, { - bool recursive = true, - }) => - setState((state) => state..replaceAll(fn, recursive: recursive)); - - @override - Future setArguments(void Function(Map args) change) => - setState((state) { - change(state.arguments); - return state; - }); -} - -base mixin _OctopusTransactionMixin on Octopus, _OctopusMethodsMixin { - Completer? _txnCompleter; - final Queue<(OctopusState Function(OctopusState$Mutable), int)> _txnQueue = - Queue<(OctopusState Function(OctopusState$Mutable), int)>(); - - @override - Future transaction( - OctopusState Function(OctopusState$Mutable state) change, { - int? priority, - }) async { - Completer completer; - if (_txnCompleter == null || _txnCompleter!.isCompleted) { - completer = _txnCompleter = Completer.sync(); - Future.delayed(Duration.zero, () { - var mutableState = state.mutate() - ..intention = OctopusStateIntention.auto; - final list = _txnQueue.toList(growable: false) - ..sort((a, b) => b.$2.compareTo(a.$2)); - _txnQueue.clear(); - for (final fn in list) { - try { - mutableState = switch (fn.$1(mutableState)) { - OctopusState$Mutable state => state, - OctopusState$Immutable state => state.mutate(), - }; - } on Object {/* ignore */} - } - setState((_) => mutableState); - if (completer.isCompleted) return; - completer.complete(); - }); - } else { - completer = _txnCompleter!; - } - priority ??= _txnQueue.fold(0, (p, e) => math.min(p, e.$2)) - 1; - _txnQueue.add((change, priority)); - return completer.future; - } -} - -/// {@template octopus_config} -/// Creates a [OctopusConfig] as a [RouterConfig]. -/// {@endtemplate} -class OctopusConfig implements RouterConfig { - /// {@macro octopus_config} - OctopusConfig({ - required this.routes, - required this.routeInformationProvider, - required this.routeInformationParser, - required this.routerDelegate, - required this.backButtonDispatcher, - }); - - /// The [OctopusRoute]s that are used to configure the [Router]. - final Map routes; - - /// The [RouteInformationProvider] that is used to configure the [Router]. - @override - final OctopusInformationProvider routeInformationProvider; - - /// The [RouteInformationParser] that is used to configure the [Router]. - @override - final OctopusInformationParser routeInformationParser; - - /// The [RouterDelegate] that is used to configure the [Router]. - @override - final OctopusDelegate routerDelegate; - - /// The [BackButtonDispatcher] that is used to configure the [Router]. - @override - final BackButtonDispatcher backButtonDispatcher; -} diff --git a/lib/src/controller/singleton.dart b/lib/src/controller/singleton.dart new file mode 100644 index 0000000..68d2d38 --- /dev/null +++ b/lib/src/controller/singleton.dart @@ -0,0 +1,6 @@ +import 'package:meta/meta.dart'; +import 'package:octopus/src/controller/controller.dart'; + +/// {@nodoc} +@internal +Octopus? $octopusSingletonInstance; diff --git a/lib/src/controller/typedefs.dart b/lib/src/controller/typedefs.dart new file mode 100644 index 0000000..649d2fd --- /dev/null +++ b/lib/src/controller/typedefs.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; + +/// Builder for the unknown route. +typedef NotFoundBuilder = Widget Function( + BuildContext ctx, + String name, + Map arguments, +); diff --git a/lib/src/widget/bucket_navigator.dart b/lib/src/widget/bucket_navigator.dart index 2204479..ee91f86 100644 --- a/lib/src/widget/bucket_navigator.dart +++ b/lib/src/widget/bucket_navigator.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:octopus/src/controller/delegate.dart'; -import 'package:octopus/src/controller/octopus.dart'; +import 'package:octopus/src/controller/controller.dart'; +import 'package:octopus/src/controller/observer.dart'; import 'package:octopus/src/state/state.dart'; import 'package:octopus/src/widget/build_context_extension.dart'; import 'package:octopus/src/widget/navigator.dart'; @@ -99,6 +99,9 @@ class _BucketNavigatorState extends State Widget build(BuildContext context) { final node = _node; if (node == null || !node.hasChildren) return const SizedBox.shrink(); + final pages = + _router.config.routerDelegate.buildPages(context, node.children); + if (pages.isEmpty) return const SizedBox.shrink(); return InheritedOctopusRoute( node: node, child: OctopusNavigator( @@ -112,11 +115,7 @@ class _BucketNavigatorState extends State (NoAnimationScope.of(context) ? const NoAnimationTransitionDelegate() : const DefaultTransitionDelegate()), - pages: _router.config.routerDelegate.buildPagesFromNodes( - context, - node.children, - const _EmptyNestedRoute(), - ), + pages: pages, onPopPage: _onPopPage, ), ); @@ -167,17 +166,6 @@ class _BucketNavigatorState extends State } } -class _EmptyNestedRoute with OctopusRoute { - const _EmptyNestedRoute(); - - @override - String get name => 'not-found'; - - @override - Widget builder(BuildContext context, OctopusNode node) => - const SizedBox.shrink(); -} - /// {@nodoc} mixin _BackButtonBucketNavigatorStateMixin on State { BackButtonDispatcher? dispatcher; diff --git a/lib/src/widget/build_context_extension.dart b/lib/src/widget/build_context_extension.dart index 810c2dc..a5938d4 100644 --- a/lib/src/widget/build_context_extension.dart +++ b/lib/src/widget/build_context_extension.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart' show BuildContext; -import 'package:octopus/src/controller/octopus.dart'; +import 'package:octopus/src/controller/controller.dart'; import 'package:octopus/src/state/state.dart'; import 'package:octopus/src/widget/inherited_octopus.dart'; diff --git a/lib/src/widget/inherited_octopus.dart b/lib/src/widget/inherited_octopus.dart index 8d29bc7..16c7391 100644 --- a/lib/src/widget/inherited_octopus.dart +++ b/lib/src/widget/inherited_octopus.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:octopus/src/controller/octopus.dart'; +import 'package:octopus/src/controller/controller.dart'; import 'package:octopus/src/state/state.dart'; /// InheritedOctopus widget. diff --git a/lib/src/widget/tools.dart b/lib/src/widget/tools.dart index 3f07629..8eb6989 100644 --- a/lib/src/widget/tools.dart +++ b/lib/src/widget/tools.dart @@ -4,8 +4,8 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:octopus/src/controller/delegate.dart'; -import 'package:octopus/src/controller/octopus.dart'; +import 'package:octopus/src/controller/controller.dart'; +import 'package:octopus/src/controller/observer.dart'; import 'package:octopus/src/state/state.dart'; /// {@template octopus_tools}