diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist
index 907f329..433c8cb 100644
--- a/example/ios/Runner/Info.plist
+++ b/example/ios/Runner/Info.plist
@@ -45,5 +45,20 @@
CADisableMinimumFrameDurationOnPhone
+ FlutterDeepLinkingEnabled
+
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLName
+ myapp
+ CFBundleURLSchemes
+
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+
+
+
diff --git a/example/lib/authentication_flow/authentication_flow.dart b/example/lib/authentication_flow/authentication_flow.dart
index 5ccdb91..3e4313f 100644
--- a/example/lib/authentication_flow/authentication_flow.dart
+++ b/example/lib/authentication_flow/authentication_flow.dart
@@ -21,6 +21,7 @@ class AuthenticationFlow extends StatelessWidget {
static Route route() {
return MaterialPageRoute(
+ settings: const RouteSettings(name: '/auth'),
builder: (_) => BlocProvider(
create: (_) => AuthenticationCubit(),
child: const AuthenticationFlow._(),
@@ -28,6 +29,16 @@ class AuthenticationFlow extends StatelessWidget {
);
}
+ static Page page() {
+ return MaterialPage(
+ name: '/auth',
+ child: BlocProvider(
+ create: (_) => AuthenticationCubit(),
+ child: const AuthenticationFlow._(),
+ ),
+ );
+ }
+
@override
Widget build(BuildContext context) {
return FlowBuilder(
diff --git a/example/lib/location_flow/location_flow.dart b/example/lib/location_flow/location_flow.dart
index a34876e..5482e6b 100644
--- a/example/lib/location_flow/location_flow.dart
+++ b/example/lib/location_flow/location_flow.dart
@@ -25,15 +25,27 @@ List> onGenerateLocationPages(
class LocationFlow extends StatelessWidget {
const LocationFlow._();
+ static Page page() => const MaterialPage(child: LocationFlow._());
+
static Route route() {
- return MaterialPageRoute(builder: (_) => const LocationFlow._());
+ return MaterialPageRoute(
+ settings: const RouteSettings(name: '/location'),
+ builder: (_) => const LocationFlow._(),
+ );
}
@override
Widget build(BuildContext context) {
- return const FlowBuilder(
- state: Location(),
+ return FlowBuilder(
+ state: const Location(),
onGeneratePages: onGenerateLocationPages,
+ onLocationChanged: (location, state) {
+ return Location(
+ country: location.queryParameters['country'],
+ city: location.queryParameters['city'],
+ state: location.queryParameters['state'],
+ );
+ },
);
}
}
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 406b16a..849a10e 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -2,6 +2,7 @@ import 'package:example/authentication_flow/authentication_flow.dart';
import 'package:example/location_flow/location_flow.dart';
import 'package:example/onboarding_flow/onboarding_flow.dart';
import 'package:example/profile_flow/profile_flow.dart';
+import 'package:flow_builder/flow_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -17,7 +18,34 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return RepositoryProvider.value(
value: _locationRepository,
- child: const MaterialApp(home: Home()),
+ child: const MaterialApp(home: UrlFlowBuilder()),
+ );
+ }
+}
+
+class UrlFlowBuilder extends StatelessWidget {
+ const UrlFlowBuilder({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return FlowBuilder(
+ state: Uri(path: '/'),
+ onGeneratePages: (uri, pages) {
+ if (uri.pathSegments.isEmpty) return [Home.page()];
+ return [
+ Home.page(),
+ if (uri.pathSegments.first == 'profile') ProfileFlow.page(),
+ if (uri.pathSegments.first == 'onboarding') OnboardingFlow.page(),
+ if (uri.pathSegments.first == 'location') LocationFlow.page(),
+ if (uri.pathSegments.first == 'auth') AuthenticationFlow.page(),
+ ];
+ },
+ onLocationChanged: (location, state) => location,
+ onDidPop: (dynamic result) {
+ ScaffoldMessenger.of(context)
+ ..hideCurrentSnackBar()
+ ..showSnackBar(SnackBar(content: Text('$result')));
+ },
);
}
}
@@ -25,6 +53,8 @@ class MyApp extends StatelessWidget {
class Home extends StatefulWidget {
const Home({super.key});
+ static Page page() => const MaterialPage(name: '/', child: Home());
+
@override
State createState() => _HomeState();
}
diff --git a/example/lib/onboarding_flow/onboarding_flow.dart b/example/lib/onboarding_flow/onboarding_flow.dart
index 295c25a..7dba9a0 100644
--- a/example/lib/onboarding_flow/onboarding_flow.dart
+++ b/example/lib/onboarding_flow/onboarding_flow.dart
@@ -15,6 +15,7 @@ List> onGenerateOnboardingPages(
List> pages,
) {
switch (state) {
+ case OnboardingState.onboardingComplete:
case OnboardingState.usageComplete:
return [
OnboardingWelcome.page(),
@@ -27,7 +28,6 @@ List> onGenerateOnboardingPages(
OnboardingUsage.page(),
];
case OnboardingState.initial:
- case OnboardingState.onboardingComplete:
return [OnboardingWelcome.page()];
}
}
@@ -35,16 +35,29 @@ List> onGenerateOnboardingPages(
class OnboardingFlow extends StatelessWidget {
const OnboardingFlow._();
+ static Page page() {
+ return const MaterialPage(child: OnboardingFlow._());
+ }
+
static Route route() {
- return MaterialPageRoute(builder: (_) => const OnboardingFlow._());
+ return MaterialPageRoute(
+ settings: const RouteSettings(name: '/onboarding/0'),
+ builder: (_) => const OnboardingFlow._(),
+ );
}
@override
Widget build(BuildContext context) {
- return FlowBuilder(
+ return FlowBuilder(
state: OnboardingState.initial,
observers: [HeroController()],
onGeneratePages: onGenerateOnboardingPages,
+ onLocationChanged: (location, state) {
+ final index = location.pathSegments.length > 1
+ ? int.tryParse(location.pathSegments[1]) ?? 0
+ : 0;
+ return OnboardingState.values[index];
+ },
);
}
}
diff --git a/example/lib/profile_flow/profile_flow.dart b/example/lib/profile_flow/profile_flow.dart
index ae66cac..0e890cd 100644
--- a/example/lib/profile_flow/profile_flow.dart
+++ b/example/lib/profile_flow/profile_flow.dart
@@ -2,30 +2,44 @@ import 'package:equatable/equatable.dart';
import 'package:flow_builder/flow_builder.dart';
import 'package:flutter/material.dart';
-List> onGenerateProfilePages(
- Profile profile,
- List> pages,
-) {
- return [
- const MaterialPage(child: ProfileNameForm(), name: '/profile'),
- if (profile.name != null) const MaterialPage(child: ProfileAgeForm()),
- if (profile.age != null)
- const MaterialPage(child: ProfileWeightForm()),
- ];
-}
-
class ProfileFlow extends StatelessWidget {
const ProfileFlow._();
+ static Page page() => const MaterialPage(child: ProfileFlow._());
+
static Route route() {
- return MaterialPageRoute(builder: (_) => const ProfileFlow._());
+ return MaterialPageRoute(
+ settings: const RouteSettings(name: '/profile'),
+ builder: (_) => const ProfileFlow._(),
+ );
}
@override
Widget build(BuildContext context) {
- return const FlowBuilder(
- state: Profile(),
- onGeneratePages: onGenerateProfilePages,
+ return FlowBuilder(
+ state: const Profile(),
+ onGeneratePages: (Profile profile, List> pages) {
+ return [
+ const MaterialPage(child: ProfileNameForm(), name: '/profile'),
+ if (profile.name != null)
+ MaterialPage(
+ child: const ProfileAgeForm(),
+ name: '/profile?name=${profile.name}',
+ ),
+ if (profile.age != null)
+ MaterialPage(
+ child: const ProfileWeightForm(),
+ name: '/profile?name=${profile.name}&age=${profile.age}',
+ ),
+ ];
+ },
+ onLocationChanged: (location, state) {
+ return Profile(
+ name: location.queryParameters['name'],
+ weight: int.tryParse(location.queryParameters['weight'] ?? ''),
+ age: int.tryParse(location.queryParameters['age'] ?? ''),
+ );
+ },
);
}
}
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index bd02c28..a3e62a9 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -13,6 +13,8 @@ dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.0.0
+ flutter_web_plugins:
+ sdk: flutter
dev_dependencies:
very_good_analysis: ^3.0.1
diff --git a/lib/flow_builder.dart b/lib/flow_builder.dart
index 0daff83..355f5ce 100644
--- a/lib/flow_builder.dart
+++ b/lib/flow_builder.dart
@@ -5,6 +5,14 @@ import 'dart:collection';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
+/// Signature for function which given a location [Uri] and
+/// a state [T] is responsible for returning a new state [T].
+typedef OnLocationChanged = T Function(Uri location, T state);
+
+/// Signature for function which given a result [T] will
+/// call an action to handle the current [Route] pop.
+typedef OnDidPop = void Function(T result);
+
/// Signature for function which generates a [List] given an input of [T]
/// and the current [List].
typedef OnGeneratePages = List> Function(
@@ -44,6 +52,8 @@ class FlowBuilder extends StatefulWidget {
const FlowBuilder({
super.key,
required this.onGeneratePages,
+ this.onLocationChanged,
+ this.onDidPop,
this.state,
this.onComplete,
this.controller,
@@ -60,6 +70,12 @@ class FlowBuilder extends StatefulWidget {
/// Builds a [List] based on the current state.
final OnGeneratePages onGeneratePages;
+ /// Optional callback which handles [Uri] changes.
+ final OnLocationChanged? onLocationChanged;
+
+ /// Optional callback which is called when the current [Route] pops.
+ final OnDidPop? onDidPop;
+
/// Optional [ValueSetter] which is invoked when the
/// flow has been completed with the final flow state.
final ValueSetter? onComplete;
@@ -86,6 +102,7 @@ class _FlowBuilderState extends State> {
var _didPop = false;
late final GlobalObjectKey _navigatorKey;
NavigatorState? get _navigator => _navigatorKey.currentState;
+ Uri get _location => _SystemNavigationObserver._location;
T get _state => _controller.state;
bool get _canPop => _pages.length > 1 || (_navigator?.canPop() ?? false);
@@ -93,8 +110,17 @@ class _FlowBuilderState extends State> {
void initState() {
super.initState();
_navigatorKey = GlobalObjectKey(this);
- _SystemNavigationObserver.add(_pop);
- _controller = _initController(widget.state);
+ _SystemNavigationObserver.addPopInterceptor(_pop);
+ if (widget.onLocationChanged != null) {
+ final state = widget.onLocationChanged!(
+ _location,
+ widget.state ?? widget.controller!.state,
+ );
+ _controller = _initController(state);
+ _SystemNavigationObserver.addPushInterceptor(_push);
+ } else {
+ _controller = _initController(widget.state);
+ }
_pages = widget.onGeneratePages(_state, List.of(_pages));
_history.add(_state);
}
@@ -132,7 +158,8 @@ class _FlowBuilderState extends State> {
@override
void dispose() {
- _SystemNavigationObserver.remove(_pop);
+ _SystemNavigationObserver.removePopInterceptor(_pop);
+ _SystemNavigationObserver.removePushInterceptor(_push);
_removeListeners(dispose: widget.controller == null);
super.dispose();
}
@@ -147,6 +174,13 @@ class _FlowBuilderState extends State> {
return false;
}
+ Future _push(Uri location) async {
+ if (!mounted) return;
+ final onLocationChanged = widget.onLocationChanged;
+ if (onLocationChanged == null) return;
+ _controller.update((state) => onLocationChanged(location, state));
+ }
+
void _listener() {
if (_controller.completed) {
_controller.removeListener(_listener);
@@ -178,15 +212,22 @@ class _FlowBuilderState extends State> {
child: Navigator(
key: _navigatorKey,
pages: _pages,
- observers: widget.observers,
+ observers: [_FlowNavigatorObserver(), ...widget.observers],
onPopPage: (route, dynamic result) {
if (_history.length > 1) {
_history.removeLast();
_didPop = true;
+ widget.onDidPop?.call(result);
_controller.update((_) => _history.last);
}
- if (_pages.length > 1) {
- _pages.removeLast();
+ if (_pages.length > 1) _pages.removeLast();
+ final onLocationChanged = widget.onLocationChanged;
+ final pageLocation = _pages.last.name;
+ if (onLocationChanged != null && pageLocation != null) {
+ _SystemNavigationObserver._updateLocation(pageLocation);
+ _controller.update(
+ (state) => onLocationChanged(Uri.parse(pageLocation), state),
+ );
}
setState(() {});
return route.didPop(result);
@@ -338,16 +379,59 @@ class _ConditionalWillPopScope extends StatelessWidget {
}
}
+/// Default [NavigatorObserver] for every [FlowBuilder].
+class _FlowNavigatorObserver extends NavigatorObserver {
+ @override
+ void didPush(Route route, Route? previousRoute) {
+ super.didPush(route, previousRoute);
+ if (route.settings.name != null) {
+ _SystemNavigationObserver._updateLocation(route.settings.name);
+ }
+ }
+
+ @override
+ void didPop(Route route, Route? previousRoute) {
+ super.didPop(route, previousRoute);
+ if (previousRoute?.settings.name != null) {
+ _SystemNavigationObserver._updateLocation(previousRoute?.settings.name);
+ }
+ }
+
+ @override
+ void didReplace({Route? newRoute, Route? oldRoute}) {
+ super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
+ if (newRoute?.settings.name != null) {
+ _SystemNavigationObserver._updateLocation(newRoute?.settings.name);
+ }
+ }
+}
+
abstract class _SystemNavigationObserver implements WidgetsBinding {
- static final _interceptors = ListQueue>>();
+ static final _popInterceptors = ListQueue>>();
+ static final _pushInterceptors = ListQueue Function(Uri)>();
+
+ static Uri _location = _rootLocation;
+
+ static void _updateLocation(String? path) {
+ _location = path != null ? Uri.parse(path) : _rootLocation;
+ }
+
+ static void addPopInterceptor(ValueGetter> interceptor) {
+ _popInterceptors.addFirst(interceptor);
+ SystemChannels.navigation.setMethodCallHandler(_handleSystemNavigation);
+ }
- static void add(ValueGetter> interceptor) {
- _interceptors.addFirst(interceptor);
+ static void addPushInterceptor(Future Function(Uri) interceptor) {
+ _pushInterceptors.addLast(interceptor);
SystemChannels.navigation.setMethodCallHandler(_handleSystemNavigation);
}
- static void remove(ValueGetter> interceptor) {
- _interceptors.remove(interceptor);
+ static void removePopInterceptor(ValueGetter> interceptor) {
+ _popInterceptors.remove(interceptor);
+ }
+
+ static void removePushInterceptor(Future Function(Uri) interceptor) {
+ _pushInterceptors.remove(interceptor);
}
static Future _handleSystemNavigation(MethodCall methodCall) {
@@ -362,7 +446,7 @@ abstract class _SystemNavigationObserver implements WidgetsBinding {
}
static Future _popRoute() async {
- for (final interceptor in _interceptors) {
+ for (final interceptor in _popInterceptors) {
final preventDefault = await interceptor();
if (preventDefault) return Future.value();
}
@@ -371,18 +455,44 @@ abstract class _SystemNavigationObserver implements WidgetsBinding {
static Future _pushRoute(dynamic arguments) async {
if (arguments is String) {
- return WidgetsBinding.instance.handlePushRoute(arguments);
+ // ignore: parameter_assignments
+ arguments = arguments.isEmpty ? _rootPath : arguments;
+ final uri = Uri.parse(arguments);
+ if (_location == uri) return;
+ _location = uri;
+ if (_pushInterceptors.isEmpty) {
+ return WidgetsBinding.instance.handlePushRoute(arguments);
+ }
+ await _pushInterceptors.first.call(uri);
} else {
return Future.value();
}
}
+
+ static Future _handlePlatformMessage(MethodCall methodCall) {
+ return ServicesBinding.instance.defaultBinaryMessenger
+ .handlePlatformMessage(
+ 'flutter/navigation',
+ const JSONMethodCodec().encodeMethodCall(methodCall),
+ (_) {},
+ );
+ }
}
/// Visible for testing system navigation.
abstract class TestSystemNavigationObserver {
- /// Visible for testing system pop navigation.
+ /// Visible for testing system push/pop navigation.
@visibleForTesting
static Future handleSystemNavigation(MethodCall methodCall) {
return _SystemNavigationObserver._handleSystemNavigation(methodCall);
}
+
+ /// Visible for testing system platform message navigation.
+ @visibleForTesting
+ static Future handlePlatformMessage(MethodCall methodCall) {
+ return _SystemNavigationObserver._handlePlatformMessage(methodCall);
+ }
}
+
+const _rootPath = '/';
+final _rootLocation = Uri(path: _rootPath);
diff --git a/test/flow_builder_test.dart b/test/flow_builder_test.dart
index ce4f51c..0ddbf1c 100644
--- a/test/flow_builder_test.dart
+++ b/test/flow_builder_test.dart
@@ -49,6 +49,25 @@ void main() {
});
});
+ testWidgets(
+ 'initializes controller when onLocationChanged callback is provided',
+ (tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: FlowBuilder(
+ state: 0,
+ onGeneratePages: (dynamic state, pages) {
+ return const >[
+ MaterialPage(child: SizedBox()),
+ ];
+ },
+ onLocationChanged: (Uri _, dynamic __) => '',
+ ),
+ ),
+ );
+ expect(find.byType(FlowBuilder), findsOneWidget);
+ });
+
testWidgets(
'throws FlutterError when context.flow is called '
'outside of FlowBuilder', (tester) async {
@@ -991,6 +1010,48 @@ void main() {
expect(observer.pushCount, 0);
widgetsBinding.removeObserver(observer);
});
+
+ // testWidgets(
+ // 'changes controller state to onLocationChanged callback',
+ // (tester) async {
+ // const testRouteName = '/testRouteName1';
+ // const firstPageKey = Key('__first_page__');
+ // const secondPageKey = Key('__second_page__');
+
+ // await tester.pumpWidget(
+ // MaterialApp(
+ // home: FlowBuilder(
+ // state: Uri(path: '/'),
+ // onGeneratePages: (uri, pages) {
+ // return >[
+ // if (uri.pathSegments.isEmpty)
+ // const MaterialPage(
+ // child: Scaffold(key: firstPageKey),
+ // ),
+ // if (uri.pathSegments.first == testRouteName)
+ // const MaterialPage(
+ // child: Scaffold(key: secondPageKey),
+ // ),
+ // ];
+ // },
+ // onLocationChanged: (location, state) => location,
+ // ),
+ // ),
+ // );
+
+ // const testRouteInformation = {
+ // 'location': testRouteName,
+ // 'state': 'state',
+ // };
+ // await TestSystemNavigationObserver.handlePlatformMessage(
+ // const MethodCall('pushRouteInformation', testRouteInformation),
+ // );
+ // await Future.microtask(() => {});
+ // await tester.pumpAndSettle();
+
+ // expect(find.byKey(secondPageKey), findsOneWidget);
+ // },
+ // );
});
testWidgets('system pop does not terminate flow', (tester) async {
@@ -1377,7 +1438,7 @@ void main() {
);
final navigators = tester.widgetList(find.byType(Navigator));
- expect(navigators.last.observers, equals(observers));
+ expect(navigators.last.observers, containsAll(observers));
});
testWidgets('SystemNavigator.pop respects when WillPopScope returns false',