diff --git a/CHANGELOG.md b/CHANGELOG.md index 50bb696..c0ef839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.2.0 + +- feat: add `onLocationChanged` for deep link/location-driven flow state ([#41](https://github.com/felangel/flow_builder/issues/41)) + - Only the outermost (root) `FlowBuilder` reacts to system-level deep links; nested `FlowBuilder`s should derive their own initial state from the location their parent hands them. + # 0.1.1 - fix: forward `pushRouteInformation` to `WidgetsBinding` so deep linking keeps working alongside other Navigator 2.0 routers (e.g. go_router, auto_route) while a `FlowBuilder` is mounted ([#117](https://github.com/felangel/flow_builder/issues/117)) diff --git a/lib/flow_builder.dart b/lib/flow_builder.dart index 93ae110..7f494db 100644 --- a/lib/flow_builder.dart +++ b/lib/flow_builder.dart @@ -20,6 +20,14 @@ typedef OnGeneratePages = List> Function( /// [FlowController.complete]. typedef FlowCallback = T Function(T state); +/// Signature for function which given a [RouteInformation] (e.g. a deep +/// link) and the current flow state [T] returns an updated flow state [T]. +/// +/// Used by [FlowBuilder.onLocationChanged] to derive flow state from the +/// app's current location, both at cold start (the platform's default +/// route) and at runtime (a system `pushRouteInformation` call). +typedef OnLocationChanged = T Function(T state, RouteInformation location); + /// {@template flow_builder} /// [FlowBuilder] abstracts navigation and exposes a declarative routing API /// based on a [state]. @@ -47,6 +55,7 @@ class FlowBuilder extends StatefulWidget { this.state, this.onComplete, this.controller, + this.onLocationChanged, this.observers = const [], this.clipBehavior = Clip.hardEdge, super.key, @@ -73,6 +82,20 @@ class FlowBuilder extends StatefulWidget { /// If not provided, a [FlowController] instance will be created internally. final FlowController? controller; + /// Optional callback invoked with the current [RouteInformation] to + /// derive an updated flow state, both at cold start (the platform's + /// default route) and at runtime (a system `pushRouteInformation` call, + /// e.g. a deep link received while the app is in the foreground). + /// + /// Only the outermost (root) [FlowBuilder] — one with no ancestor + /// [FlowBuilder] — reacts to system-level deep links. A nested + /// [FlowBuilder] should derive its own initial state from the location + /// its parent hands it when constructing its page. + /// + /// Has no effect if [state] is not provided (i.e. when using + /// [controller]), and has no effect on how the flow pops. + final OnLocationChanged? onLocationChanged; + /// A list of [NavigatorObserver] for this [FlowBuilder]. final List observers; @@ -92,6 +115,7 @@ class _FlowBuilderState extends State> { final _history = ListQueue(); var _pages = >[]; var _didPop = false; + late final bool _isRootFlow; late final GlobalObjectKey _navigatorKey; NavigatorState? get _navigator => _navigatorKey.currentState; T get _state => _controller.state; @@ -101,12 +125,37 @@ class _FlowBuilderState extends State> { void initState() { super.initState(); _navigatorKey = GlobalObjectKey(this); + _isRootFlow = + context.getElementForInheritedWidgetOfExactType<_FlowScope>() == null; _SystemNavigationObserver.add(_pop); - _controller = _initController(widget.state); + + var initialState = widget.state; + final onLocationChanged = widget.onLocationChanged; + if (_isRootFlow && onLocationChanged != null && initialState != null) { + _SystemNavigationObserver.addLocationInterceptor( + _onSystemLocationChanged, + ); + final defaultLocation = RouteInformation( + uri: Uri.parse( + WidgetsBinding.instance.platformDispatcher.defaultRouteName, + ), + ); + initialState = onLocationChanged(initialState, defaultLocation); + } + + _controller = _initController(initialState); _pages = widget.onGeneratePages(_state, List.of(_pages)); _history.add(_state); } + Future _onSystemLocationChanged(RouteInformation location) async { + if (!mounted) return false; + final onLocationChanged = widget.onLocationChanged; + if (onLocationChanged == null) return false; + _controller.update((state) => onLocationChanged(state, location)); + return true; + } + @override void didUpdateWidget(FlowBuilder oldWidget) { super.didUpdateWidget(oldWidget); @@ -141,6 +190,11 @@ class _FlowBuilderState extends State> { @override void dispose() { _SystemNavigationObserver.remove(_pop); + if (_isRootFlow && widget.onLocationChanged != null) { + _SystemNavigationObserver.removeLocationInterceptor( + _onSystemLocationChanged, + ); + } _removeListeners(dispose: widget.controller == null); super.dispose(); } @@ -175,28 +229,30 @@ class _FlowBuilderState extends State> { @override Widget build(BuildContext context) { - return _InheritedFlowController( - controller: _controller, - child: _ConditionalPopScope( - condition: _canPop, - child: Navigator( - key: _navigatorKey, - pages: _pages, - observers: widget.observers, - clipBehavior: widget.clipBehavior, - onPopPage: (route, dynamic result) { - if (_history.length > 1) { - _history.removeLast(); - _didPop = true; - _controller.update((_) => _history.last); - } - if (_pages.length > 1) { - _pages.removeLast(); - } - setState(() {}); - route.onPopInvoked(true); - return route.didPop(result); - }, + return _FlowScope( + child: _InheritedFlowController( + controller: _controller, + child: _ConditionalPopScope( + condition: _canPop, + child: Navigator( + key: _navigatorKey, + pages: _pages, + observers: widget.observers, + clipBehavior: widget.clipBehavior, + onPopPage: (route, dynamic result) { + if (_history.length > 1) { + _history.removeLast(); + _didPop = true; + _controller.update((_) => _history.last); + } + if (_pages.length > 1) { + _pages.removeLast(); + } + setState(() {}); + route.onPopInvoked(true); + return route.didPop(result); + }, + ), ), ), ); @@ -337,8 +393,20 @@ class _ConditionalPopScope extends StatelessWidget { } } +/// Marker widget used to detect whether a [FlowBuilder] has an ancestor +/// [FlowBuilder], so only the outermost (root) one reacts to system-level +/// deep links. +class _FlowScope extends InheritedWidget { + const _FlowScope({required super.child}); + + @override + bool updateShouldNotify(_FlowScope oldWidget) => false; +} + abstract class _SystemNavigationObserver implements WidgetsBinding { static final _interceptors = ListQueue>>(); + static final _locationInterceptors = + ListQueue Function(RouteInformation)>(); static void add(ValueGetter> interceptor) { _interceptors.addFirst(interceptor); @@ -349,6 +417,18 @@ abstract class _SystemNavigationObserver implements WidgetsBinding { _interceptors.remove(interceptor); } + static void addLocationInterceptor( + Future Function(RouteInformation) interceptor, + ) { + _locationInterceptors.addFirst(interceptor); + } + + static void removeLocationInterceptor( + Future Function(RouteInformation) interceptor, + ) { + _locationInterceptors.remove(interceptor); + } + static Future _handleSystemNavigation(MethodCall methodCall) { switch (methodCall.method) { case 'popRoute': @@ -384,14 +464,25 @@ abstract class _SystemNavigationObserver implements WidgetsBinding { // (go_router, auto_route, MaterialApp.router) silently stops receiving // deep links the moment a FlowBuilder mounts (felangel/flow_builder#117). // - // `WidgetsBinding` only exposes `handlePushRoute` publicly; internally it - // builds the same `RouteInformation` and notifies observers via - // `didPushRouteInformation`, so this reaches the same listeners the - // framework's own `pushRouteInformation` handling would. + // A root FlowBuilder with `onLocationChanged` set gets first chance at the + // location via `_locationInterceptors` (mirroring `_popRoute`'s + // try-mine-then-fallback shape above). If none claim it, it falls through + // to `WidgetsBinding.handlePushRoute` — the only publicly exposed method + // that performs the same observer fan-out (`didPushRouteInformation`) the + // framework's own `pushRouteInformation` handling would, so other + // Router-based packages keep working either way. static Future _pushRouteInformation(dynamic arguments) async { if (arguments is Map) { final location = arguments['location'] as String?; if (location == null) return Future.value(); + final routeInformation = RouteInformation( + uri: Uri.parse(location), + state: arguments['state'], + ); + for (final interceptor in _locationInterceptors) { + final handled = await interceptor(routeInformation); + if (handled) return Future.value(); + } return WidgetsBinding.instance.handlePushRoute(location); } else { return Future.value(); diff --git a/pubspec.yaml b/pubspec.yaml index 196a908..381bd81 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://github.com/felangel/flow_builder topics: [navigation, routing] funding: [https://github.com/sponsors/felangel] -version: 0.1.1 +version: 0.2.0 environment: sdk: ">=3.2.0 <4.0.0" diff --git a/test/flow_builder_test.dart b/test/flow_builder_test.dart index f5a8f82..60d2437 100644 --- a/test/flow_builder_test.dart +++ b/test/flow_builder_test.dart @@ -1060,6 +1060,141 @@ void main() { }); }); + group('onLocationChanged', () { + testWidgets( + 'seeds initial state from the cold-start default route ' + '(felangel/flow_builder#41)', (tester) async { + addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue); + tester.platformDispatcher.defaultRouteNameTestValue = + '/profile?name=Alice'; + + String? seededState; + await tester.pumpWidget( + MaterialApp( + // WidgetsApp's own root Navigator also resolves + // defaultRouteName independently of FlowBuilder; provide a + // catch-all so it doesn't report an unresolved initial route. + onGenerateRoute: (settings) => + MaterialPageRoute(builder: (_) => const SizedBox()), + home: FlowBuilder( + state: '', + onLocationChanged: (state, location) { + return location.uri.queryParameters['name'] ?? state; + }, + onGeneratePages: (state, pages) { + seededState = state; + return >[ + MaterialPage(child: Text(state)), + ]; + }, + ), + ), + ); + + expect(seededState, 'Alice'); + }); + + testWidgets( + 'foreground pushRouteInformation is intercepted and updates ' + 'flow state', (tester) async { + var numBuilds = 0; + String? latestState; + await tester.pumpWidget( + MaterialApp( + home: FlowBuilder( + state: 'initial', + onLocationChanged: (state, location) => location.uri.path, + onGeneratePages: (state, pages) { + numBuilds++; + latestState = state; + return >[ + MaterialPage(child: Text(state)), + ]; + }, + ), + ), + ); + expect(numBuilds, 1); + + await tester.sendPlatformPushRouteInformation(location: '/settings'); + await tester.pumpAndSettle(); + + expect(numBuilds, 2); + expect(latestState, '/settings'); + }); + + testWidgets( + 'a FlowBuilder without onLocationChanged does not intercept ' + 'pushRouteInformation (backward compatible)', (tester) async { + final widgetsBinding = TestWidgetsFlutterBinding.ensureInitialized(); + final observer = _TestPushRouteInformationWidgetsBindingObserver(); + widgetsBinding.addObserver(observer); + + await tester.pumpWidget( + MaterialApp( + home: FlowBuilder( + state: 0, + onGeneratePages: (state, pages) { + return >[ + const MaterialPage(child: Scaffold()), + ]; + }, + ), + ), + ); + + await tester.sendPlatformPushRouteInformation(location: '/fallback'); + await tester.pumpAndSettle(); + + expect(observer.lastRouteInformation?.uri.toString(), '/fallback'); + expect(observer.pushCount, 1); + widgetsBinding.removeObserver(observer); + }); + + testWidgets('a nested FlowBuilder does not intercept system deep links', + (tester) async { + var outerCalls = 0; + var innerCalls = 0; + + await tester.pumpWidget( + MaterialApp( + home: FlowBuilder( + state: '', + onLocationChanged: (state, location) { + outerCalls++; + return location.uri.path; + }, + onGeneratePages: (state, pages) { + return >[ + MaterialPage( + child: FlowBuilder( + state: '', + onLocationChanged: (state, location) { + innerCalls++; + return location.uri.path; + }, + onGeneratePages: (innerState, innerPages) { + return >[ + const MaterialPage(child: SizedBox()), + ]; + }, + ), + ), + ]; + }, + ), + ), + ); + final outerCallsAfterMount = outerCalls; + + await tester.sendPlatformPushRouteInformation(location: '/deep'); + await tester.pumpAndSettle(); + + expect(outerCalls, outerCallsAfterMount + 1); + expect(innerCalls, 0); + }); + }); + testWidgets('system pop does not terminate flow', (tester) async { const button1Key = Key('__button1__'); const button2Key = Key('__button2__');