diff --git a/CHANGELOG.md b/CHANGELOG.md index 34df33a..ba3f036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- **Added**: `NavigationController.scope`, `NavigationScope.find` and + `NavigationScope.maybeFind` — a way to reach an outer navigator from a + nested one. `context.navigation` and `NavigationScope.of` return only the + closest controller, so a screen inside a nested `NavigationView` (a sign + in flow, a tab of a shell) could not open a route in an outer view + without passing the outer controller down by hand. Now the outer + controller gets a `scope`, and any widget below finds it: + `NavigationScope.find(context, scope: Scope.root).push(route)`. + ## 0.1.1 - **Fixed**: on Android with the predictive back gesture, the back button diff --git a/example/README.md b/example/README.md index 526c947..ff8664b 100644 --- a/example/README.md +++ b/example/README.md @@ -7,7 +7,9 @@ A small shop that uses every feature of the package: - `lib/src/app.dart` — three tabs, one `NavigationController` each, with the guards that keep them consistent. - `lib/src/screens.dart` — the screens, including a dialog that returns a - value and a product that is added to the stack of another tab. + value, a product that is added to the stack of another tab, and a + checkout with a navigator of its own that closes itself in the navigator + of the cart tab with `NavigationScope.find`. - `lib/src/navigation.dart` — `context.nav`, an extension type with the navigation shortcuts of the application and `popModals`, which closes the declarative and the imperative popups at once. diff --git a/example/lib/src/app.dart b/example/lib/src/app.dart index 09a96af..26dd26b 100644 --- a/example/lib/src/app.dart +++ b/example/lib/src/app.dart @@ -33,6 +33,9 @@ class _AppState extends State with WidgetsBindingObserver { tab: NavigationController( [_root(tab)], debugLabel: tab.name, + // A nested navigator inside the tab finds this controller by its + // scope, see the checkout. + scope: tab, // The order matters: every guard receives the result // of the previous one. guards: [ diff --git a/example/lib/src/routes.dart b/example/lib/src/routes.dart index cf4ebfe..4488394 100644 --- a/example/lib/src/routes.dart +++ b/example/lib/src/routes.dart @@ -23,7 +23,10 @@ enum Routes with NavigationRoute { signIn, /// Regular screen pushed on top of the account tab. - settings; + settings, + + /// A flow with a navigator of its own, pushed on top of the cart tab. + checkout; /// Marks the screens a signed out user is allowed to see. static const String tagUnauthenticated = 'unauthenticated'; @@ -48,6 +51,22 @@ enum Routes with NavigationRoute { Routes.account => const AccountScreen(), Routes.signIn => const SignInScreen(), Routes.settings => const SettingsScreen(), + Routes.checkout => const CheckoutScreen(), + }; +} + +/// The steps of the checkout: the routes of its own navigator. +enum CheckoutStep with NavigationRoute { + /// The first step. + address, + + /// The last step. + payment; + + @override + Widget build(BuildContext context) => switch (this) { + CheckoutStep.address => const AddressStepScreen(), + CheckoutStep.payment => const PaymentStepScreen(), }; } diff --git a/example/lib/src/screens.dart b/example/lib/src/screens.dart index bc3bb6f..60d878f 100644 --- a/example/lib/src/screens.dart +++ b/example/lib/src/screens.dart @@ -90,8 +90,91 @@ class CartScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text('Cart')), - body: const Center( - child: Text('Add a product from the catalog to see it here'), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + const Text('Add a product from the catalog to see it here'), + FilledButton( + onPressed: () => context.nav.push(Routes.checkout), + child: const Text('Checkout'), + ), + ], + ), + ), + ); +} + +/// A flow with a navigator of its own, on top of the cart tab. +class CheckoutScreen extends StatefulWidget { + /// Creates the checkout. + const CheckoutScreen({super.key}); + + @override + State createState() => _CheckoutScreenState(); +} + +class _CheckoutScreenState extends State { + late final NavigationController _checkoutController; + + @override + void initState() { + super.initState(); + _checkoutController = NavigationController([ + CheckoutStep.address, + ], debugLabel: 'checkout'); + } + + @override + void dispose() { + _checkoutController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + // The back button of this app bar belongs to the cart tab: it closes + // the whole checkout. + appBar: AppBar(title: const Text('Checkout')), + body: NavigationView(controller: _checkoutController), + ); +} + +/// The first step of the checkout. +class AddressStepScreen extends StatelessWidget { + /// Creates the address step. + const AddressStepScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: FilledButton( + // The closest navigator is the one of the checkout. + onPressed: () => context.nav.push(CheckoutStep.payment), + child: const Text('Next'), + ), + ), + ); +} + +/// The last step of the checkout. +class PaymentStepScreen extends StatelessWidget { + /// Creates the payment step. + const PaymentStepScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: FilledButton( + // The checkout is a route of the cart tab: it is closed in the + // navigator of the tab, found by its scope. + onPressed: () => NavigationScope.find( + context, + scope: AppTab.cart, + ).remove(Routes.checkout), + child: const Text('Pay'), + ), ), ); } diff --git a/example/test/app_test.dart b/example/test/app_test.dart index 996b59a..1276b1f 100644 --- a/example/test/app_test.dart +++ b/example/test/app_test.dart @@ -131,4 +131,29 @@ void main() => group('example', () { // Nothing is left in the stack: the back button has nothing to close. expect(await tester.binding.handlePopRoute(), isFalse); }); + + testWidgets('the checkout closes itself in the navigator of its tab', ( + tester, + ) async { + Authentication.instance.signIn(); + await tester.pumpWidget(const App()); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.shopping_cart_outlined)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Checkout')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pay')); + await tester.pumpAndSettle(); + + // The whole checkout is gone, not only its last step. + expect(find.text('Pay'), findsNothing); + expect(find.text('Next'), findsNothing); + expect( + find.text('Add a product from the catalog to see it here'), + findsOneWidget, + ); + }); }); diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 50e3429..1bfb18e 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -89,6 +89,7 @@ class NavigationController List guards = const [], List observers = const [], Listenable? revalidate, + this.scope, this.debugLabel, }) : guards = List.unmodifiable(guards), observers = List.unmodifiable(observers), @@ -129,6 +130,10 @@ class NavigationController /// Observers notified after every committed change of the stack. final List observers; + /// Identifies this controller for `NavigationScope.find`, compared + /// with `==`. + final Object? scope; + /// Name of this controller, used in logs and in [toString]. final String? debugLabel; diff --git a/lib/src/view.dart b/lib/src/view.dart index 639df07..d17cf34 100644 --- a/lib/src/view.dart +++ b/lib/src/view.dart @@ -674,4 +674,53 @@ class NavigationScope extends InheritedNotifier { /// The widget is rebuilt on every change of the stack. static NavigationStack stackOf(BuildContext context) => of(context, listen: true).stack; + + /// The closest controller above [context] whose + /// [NavigationController.scope] is [scope]. + /// + /// Pass `listen: true` to rebuild the widget on every change of + /// the stack. + static NavigationController find( + BuildContext context, { + required Object scope, + bool listen = false, + }) { + final controller = maybeFind(context, scope: scope, listen: listen); + if (controller != null) return controller; + throw FlutterError.fromParts([ + ErrorSummary( + 'No NavigationScope with the scope $scope found above this widget', + ), + ErrorHint( + 'Pass the scope to the NavigationController of a view above this ' + 'widget, or use NavigationScope.maybeFind() to handle its absence.', + ), + context.describeElement('The context used was'), + ]); + } + + /// The closest controller above [context] whose + /// [NavigationController.scope] is [scope], if there is one. + /// + /// Pass `listen: true` to rebuild the widget on every change of + /// the stack. + static NavigationController? maybeFind( + BuildContext context, { + required Object scope, + bool listen = false, + }) { + InheritedElement? found; + context.visitAncestorElements((element) { + final widget = element.widget; + if (widget is! NavigationScope || widget.notifier?.scope != scope) { + return true; + } + found = element as InheritedElement; + return false; + }); + final element = found; + if (element == null) return null; + if (listen) context.dependOnInheritedElement(element); + return (element.widget as NavigationScope).notifier; + } } diff --git a/test/scope_test.dart b/test/scope_test.dart new file mode 100644 index 0000000..0dbd150 --- /dev/null +++ b/test/scope_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:squid/squid.dart'; + +import 'src/routes.dart'; + +enum _Scope { outer, middle, inner, missing } + +void main() => group('scope', () { + NavigationController controller(NavigationStack stack, {Object? scope}) { + final controller = NavigationController(stack, scope: scope); + addTearDown(controller.dispose); + return controller; + } + + Future pumpApp(WidgetTester tester, Widget home) async { + await tester.pumpWidget(MaterialApp(home: home)); + await tester.pumpAndSettle(); + } + + BuildContext contextOf(WidgetTester tester, String text) => + tester.element(find.text(text)); + + testWidgets('finds the closest controller with the scope, the closest ' + 'controller included, skipping the others', (tester) async { + final inner = controller([ + Routes.home, + ], scope: _Scope.inner); + final unscoped = controller([_ShellRoute(inner)]); + final middle = controller([ + _ShellRoute(unscoped), + ], scope: _Scope.middle); + final outer = controller([ + _ShellRoute(middle), + ], scope: _Scope.outer); + await pumpApp(tester, NavigationView(controller: outer)); + + final context = contextOf(tester, 'screen:home'); + expect(NavigationScope.find(context, scope: _Scope.inner), same(inner)); + expect(NavigationScope.find(context, scope: _Scope.middle), same(middle)); + expect(NavigationScope.find(context, scope: _Scope.outer), same(outer)); + expect( + NavigationScope.maybeFind(context, scope: _Scope.outer), + same(outer), + ); + }); + + testWidgets('the closest of the controllers with the same scope wins', ( + tester, + ) async { + final inner = controller([ + Routes.home, + ], scope: _Scope.inner); + final outer = controller([ + _ShellRoute(inner), + ], scope: _Scope.inner); + await pumpApp(tester, NavigationView(controller: outer)); + + expect( + NavigationScope.find( + contextOf(tester, 'screen:home'), + scope: _Scope.inner, + ), + same(inner), + ); + }); + + testWidgets('scopes are compared with ==', (tester) async { + final only = controller([Routes.home], scope: 'flow'); + await pumpApp(tester, NavigationView(controller: only)); + + // Another instance of an equal string. + final scope = ['fl', 'ow'].join(); + expect( + NavigationScope.find(contextOf(tester, 'screen:home'), scope: scope), + same(only), + ); + }); + + testWidgets('an unknown scope: maybeFind returns null, find throws', ( + tester, + ) async { + final inner = controller([ + Routes.home, + ], scope: _Scope.inner); + final outer = controller([ + _ShellRoute(inner), + ], scope: _Scope.outer); + await pumpApp(tester, NavigationView(controller: outer)); + + final context = contextOf(tester, 'screen:home'); + expect(NavigationScope.maybeFind(context, scope: _Scope.missing), isNull); + expect( + () => NavigationScope.find(context, scope: _Scope.missing), + throwsFlutterError, + ); + }); + + testWidgets('nothing is found without a scope above', (tester) async { + late BuildContext context; + await pumpApp( + tester, + Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox(); + }, + ), + ); + + expect(NavigationScope.maybeFind(context, scope: _Scope.outer), isNull); + expect( + () => NavigationScope.find(context, scope: _Scope.outer), + throwsFlutterError, + ); + }); + + testWidgets('a NavigationScope without a view is found as well', ( + tester, + ) async { + final standalone = controller([ + Routes.settings, + ], scope: _Scope.outer); + final view = controller([Routes.home]); + await pumpApp( + tester, + NavigationScope( + controller: standalone, + child: NavigationView(controller: view), + ), + ); + + expect( + NavigationScope.find( + contextOf(tester, 'screen:home'), + scope: _Scope.outer, + ), + same(standalone), + ); + }); + + testWidgets('views side by side with the same scope find their own: the ' + 'search follows the tree', (tester) async { + final leftInner = controller([Routes.home]); + final left = controller([ + _ShellRoute(leftInner), + ], scope: _Scope.outer); + final rightInner = controller([Routes.catalog]); + final right = controller([ + _ShellRoute(rightInner), + ], scope: _Scope.outer); + await pumpApp( + tester, + Row( + children: [ + Expanded(child: NavigationView(controller: left)), + Expanded(child: NavigationView(controller: right)), + ], + ), + ); + + expect( + NavigationScope.find( + contextOf(tester, 'screen:home'), + scope: _Scope.outer, + ), + same(left), + ); + expect( + NavigationScope.find( + contextOf(tester, 'screen:catalog'), + scope: _Scope.outer, + ), + same(right), + ); + }); + + for (final listen in [true, false]) { + testWidgets('listen: $listen depends on the found controller only', ( + tester, + ) async { + var builds = 0; + final inner = controller([ + _ProbeRoute(listen: listen, onBuild: () => builds++), + ]); + final outer = controller([ + _ShellRoute(inner), + ], scope: _Scope.outer); + await pumpApp(tester, NavigationView(controller: outer)); + final initial = builds; + + // A change of the found controller that keeps the probe on screen. + outer.change((stack) => [Routes.home, ...stack]); + await tester.pumpAndSettle(); + expect(builds, equals(listen ? initial + 1 : initial)); + + // A change of the closer controller. + inner.push(Routes.catalog); + await tester.pumpAndSettle(); + inner.pop(); + await tester.pumpAndSettle(); + expect(builds, equals(listen ? initial + 1 : initial)); + }); + } +}); + +final class _ShellRoute with NavigationRoute { + const _ShellRoute(this.controller); + + final NavigationController controller; + + @override + String get name => 'shell'; + + @override + Widget build(BuildContext context) => NavigationView(controller: controller); +} + +/// Reports its builds and finds the controller of the outer scope while +/// building. +final class _ProbeRoute with NavigationRoute { + const _ProbeRoute({required this.listen, required this.onBuild}); + + final bool listen; + + final VoidCallback onBuild; + + @override + String get name => 'probe'; + + @override + Widget build(BuildContext context) { + onBuild(); + NavigationScope.find(context, scope: _Scope.outer, listen: listen); + return const Text('probe'); + } +} diff --git a/test/squid_test.dart b/test/squid_test.dart index 4e36e51..d6da7ac 100644 --- a/test/squid_test.dart +++ b/test/squid_test.dart @@ -9,6 +9,7 @@ import 'observer_test.dart' as observer_test; import 'pages_edge_test.dart' as pages_edge_test; import 'route_test.dart' as route_test; import 'routes_edge_test.dart' as routes_edge_test; +import 'scope_test.dart' as scope_test; import 'tabs_edge_test.dart' as tabs_edge_test; import 'tabs_test.dart' as tabs_test; import 'view_test.dart' as view_test; @@ -28,4 +29,5 @@ void main() { routes_edge_test.main(); tabs_edge_test.main(); back_button_test.main(); + scope_test.main(); }