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',