Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions example/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
tab: NavigationController(
<NavigationRoute>[_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: <NavigationGuard>[
Expand Down
21 changes: 20 additions & 1 deletion example/lib/src/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
};
}

Expand Down
87 changes: 85 additions & 2 deletions example/lib/src/screens.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Widget>[
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<CheckoutScreen> createState() => _CheckoutScreenState();
}

class _CheckoutScreenState extends State<CheckoutScreen> {
late final NavigationController _checkoutController;

@override
void initState() {
super.initState();
_checkoutController = NavigationController(<NavigationRoute>[
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'),
),
),
);
}
Expand Down
25 changes: 25 additions & 0 deletions example/test/app_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
5 changes: 5 additions & 0 deletions lib/src/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class NavigationController
List<NavigationGuard> guards = const <NavigationGuard>[],
List<NavigationObserver> observers = const <NavigationObserver>[],
Listenable? revalidate,
this.scope,
this.debugLabel,
}) : guards = List<NavigationGuard>.unmodifiable(guards),
observers = List<NavigationObserver>.unmodifiable(observers),
Expand Down Expand Up @@ -129,6 +130,10 @@ class NavigationController
/// Observers notified after every committed change of the stack.
final List<NavigationObserver> 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;

Expand Down
49 changes: 49 additions & 0 deletions lib/src/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -674,4 +674,53 @@ class NavigationScope extends InheritedNotifier<NavigationController> {
/// 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(<DiagnosticsNode>[
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;
}
}
Loading