Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
145 changes: 118 additions & 27 deletions lib/flow_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ typedef OnGeneratePages<T> = List<Page<dynamic>> Function(
/// [FlowController.complete].
typedef FlowCallback<T> = 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> = T Function(T state, RouteInformation location);

/// {@template flow_builder}
/// [FlowBuilder] abstracts navigation and exposes a declarative routing API
/// based on a [state].
Expand Down Expand Up @@ -47,6 +55,7 @@ class FlowBuilder<T> extends StatefulWidget {
this.state,
this.onComplete,
this.controller,
this.onLocationChanged,
this.observers = const <NavigatorObserver>[],
this.clipBehavior = Clip.hardEdge,
super.key,
Expand All @@ -73,6 +82,20 @@ class FlowBuilder<T> extends StatefulWidget {
/// If not provided, a [FlowController] instance will be created internally.
final FlowController<T>? 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<T>? onLocationChanged;

/// A list of [NavigatorObserver] for this [FlowBuilder].
final List<NavigatorObserver> observers;

Expand All @@ -92,6 +115,7 @@ class _FlowBuilderState<T> extends State<FlowBuilder<T>> {
final _history = ListQueue<T>();
var _pages = <Page<dynamic>>[];
var _didPop = false;
late final bool _isRootFlow;
late final GlobalObjectKey<NavigatorState> _navigatorKey;
NavigatorState? get _navigator => _navigatorKey.currentState;
T get _state => _controller.state;
Expand All @@ -101,12 +125,37 @@ class _FlowBuilderState<T> extends State<FlowBuilder<T>> {
void initState() {
super.initState();
_navigatorKey = GlobalObjectKey<NavigatorState>(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<bool> _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<T> oldWidget) {
super.didUpdateWidget(oldWidget);
Expand Down Expand Up @@ -141,6 +190,11 @@ class _FlowBuilderState<T> extends State<FlowBuilder<T>> {
@override
void dispose() {
_SystemNavigationObserver.remove(_pop);
if (_isRootFlow && widget.onLocationChanged != null) {
_SystemNavigationObserver.removeLocationInterceptor(
_onSystemLocationChanged,
);
}
_removeListeners(dispose: widget.controller == null);
super.dispose();
}
Expand Down Expand Up @@ -175,28 +229,30 @@ class _FlowBuilderState<T> extends State<FlowBuilder<T>> {

@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);
},
),
),
),
);
Expand Down Expand Up @@ -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<ValueGetter<Future<bool>>>();
static final _locationInterceptors =
ListQueue<Future<bool> Function(RouteInformation)>();

static void add(ValueGetter<Future<bool>> interceptor) {
_interceptors.addFirst(interceptor);
Expand All @@ -349,6 +417,18 @@ abstract class _SystemNavigationObserver implements WidgetsBinding {
_interceptors.remove(interceptor);
}

static void addLocationInterceptor(
Future<bool> Function(RouteInformation) interceptor,
) {
_locationInterceptors.addFirst(interceptor);
}

static void removeLocationInterceptor(
Future<bool> Function(RouteInformation) interceptor,
) {
_locationInterceptors.remove(interceptor);
}

static Future<dynamic> _handleSystemNavigation(MethodCall methodCall) {
switch (methodCall.method) {
case 'popRoute':
Expand Down Expand Up @@ -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<dynamic> _pushRouteInformation(dynamic arguments) async {
if (arguments is Map) {
final location = arguments['location'] as String?;
if (location == null) return Future<dynamic>.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<dynamic>.value();
}
return WidgetsBinding.instance.handlePushRoute(location);
} else {
return Future<dynamic>.value();
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
135 changes: 135 additions & 0 deletions test/flow_builder_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(builder: (_) => const SizedBox()),
home: FlowBuilder<String>(
state: '',
onLocationChanged: (state, location) {
return location.uri.queryParameters['name'] ?? state;
},
onGeneratePages: (state, pages) {
seededState = state;
return <Page<dynamic>>[
MaterialPage<void>(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<String>(
state: 'initial',
onLocationChanged: (state, location) => location.uri.path,
onGeneratePages: (state, pages) {
numBuilds++;
latestState = state;
return <Page<dynamic>>[
MaterialPage<void>(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<int>(
state: 0,
onGeneratePages: (state, pages) {
return <Page<dynamic>>[
const MaterialPage<void>(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<String>(
state: '',
onLocationChanged: (state, location) {
outerCalls++;
return location.uri.path;
},
onGeneratePages: (state, pages) {
return <Page<dynamic>>[
MaterialPage<void>(
child: FlowBuilder<String>(
state: '',
onLocationChanged: (state, location) {
innerCalls++;
return location.uri.path;
},
onGeneratePages: (innerState, innerPages) {
return <Page<dynamic>>[
const MaterialPage<void>(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__');
Expand Down