diff --git a/packages/kaisel/CHANGELOG.md b/packages/kaisel/CHANGELOG.md index 571f2a3..3ee95d4 100644 --- a/packages/kaisel/CHANGELOG.md +++ b/packages/kaisel/CHANGELOG.md @@ -1,3 +1,13 @@ +# Changelog + +## Unreleased + +- `context.maybePop()`: pops the way the system back button does — the + `Navigator` is asked first, so local history entries (an open `Drawer`) and + `PopScope` vetoes are consulted instead of bypassed, and the kaisel stack is + only touched when the `Navigator` has nothing to pop + ([#59](https://github.com/Mastersam07/kaisel/issues/59)). + ## 1.0.0+1 No library changes. Packaging and examples only: @@ -9,8 +19,6 @@ No library changes. Packaging and examples only: - Example: `main_tutorial.dart` — the finished app from the [docs tutorial](https://kaisel.dev/tutorial/). -# Changelog - ## 1.0.0 First stable release. The API surface is frozen under semantic versioning: diff --git a/packages/kaisel/lib/src/kaisel_scope.dart b/packages/kaisel/lib/src/kaisel_scope.dart index 3b6f17a..b6e62c5 100644 --- a/packages/kaisel/lib/src/kaisel_scope.dart +++ b/packages/kaisel/lib/src/kaisel_scope.dart @@ -194,6 +194,34 @@ extension KaiselContextNavigation on BuildContext { }; } + /// Pop the way the system back button does: ask the [Navigator] first, and + /// only fall through to the kaisel stack when it has nothing to pop. + /// + /// Reach for this instead of [pop] whenever something *other than a route* + /// may be dismissible — the difference is what gets consulted: + /// + /// - **Local history entries.** Widgets that dismiss without being routes + /// (a [Drawer], a [PopupMenuButton]) register a [LocalHistoryEntry] on + /// the enclosing route. `maybePop` closes those first; [pop] would leave + /// one open and remove the screen underneath it. + /// - **[PopScope] vetoes.** A `canPop: false` scope stops the pop and gets + /// its `onPopInvokedWithResult` callback, so "intercept and redirect" + /// handlers still run. [pop] mutates the stack without asking. + /// + /// Returns whether the request was *handled* — popped, dismissed, or + /// intercepted by a veto — matching [NavigatorState.maybePop]. A `false` + /// means nothing claimed it and the back gesture should bubble to the OS. + /// + /// When the [Navigator] has nothing to pop the kaisel router is popped + /// instead, so an adaptive layout that collapses several stack entries into + /// one visible page still unwinds. + Future maybePop([Object? result]) async { + final navigator = Navigator.maybeOf(this); + if (navigator == null) return _nearestRouterScope().router.pop(result); + if (navigator.canPop()) return navigator.maybePop(result); + return _nearestRouterScope().router.pop(result); + } + /// Go back one step, history-aligned, so the browser's own Back/Forward /// buttons keep mirroring the app stack — even across several pops in a row. /// diff --git a/packages/kaisel/test/kaisel_maybe_pop_test.dart b/packages/kaisel/test/kaisel_maybe_pop_test.dart new file mode 100644 index 0000000..314466f --- /dev/null +++ b/packages/kaisel/test/kaisel_maybe_pop_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kaisel/kaisel.dart'; + +sealed class _R extends KaiselRoute { + const _R(); +} + +final class _Home extends _R { + const _Home(); +} + +final class _Detail extends _R { + const _Detail(); +} + +void main() { + testWidgets('closes a drawer instead of popping the screen under it', ( + tester, + ) async { + final router = KaiselRouter<_R>(initial: const _Home()); + late BuildContext screenContext; + final delegate = KaiselRouterDelegate<_R>( + router: router, + builder: (context, route) => switch (route) { + _Home() => const Scaffold(body: Center(child: Text('home'))), + _Detail() => Scaffold( + drawer: const Drawer(child: Text('drawer')), + body: Builder( + builder: (context) { + screenContext = context; + return ElevatedButton( + onPressed: () => Scaffold.of(context).openDrawer(), + child: const Text('open-drawer'), + ); + }, + ), + ), + }, + ); + await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate)); + + await router.push(const _Detail()); + await tester.pumpAndSettle(); + await tester.tap(find.text('open-drawer')); + await tester.pumpAndSettle(); + expect(find.text('drawer'), findsOneWidget); + + final handled = await screenContext.maybePop(); + await tester.pumpAndSettle(); + + expect(handled, isTrue); + expect(find.text('drawer'), findsNothing); + expect(router.stack, const [_Home(), _Detail()]); + }); + + testWidgets('respects a PopScope veto and reports it handled', ( + tester, + ) async { + final router = KaiselRouter<_R>(initial: const _Home()); + var vetoRuns = 0; + bool? result; + final delegate = KaiselRouterDelegate<_R>( + router: router, + builder: (context, route) => switch (route) { + _Home() => const Scaffold(body: Center(child: Text('home'))), + _Detail() => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) => vetoRuns++, + child: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async => result = await context.maybePop(), + child: const Text('maybe-pop'), + ), + ), + ), + ), + }, + ); + await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate)); + + await router.push(const _Detail()); + await tester.pumpAndSettle(); + await tester.tap(find.text('maybe-pop')); + await tester.pumpAndSettle(); + + expect(vetoRuns, 1); + expect(result, isTrue); + expect(router.stack, const [_Home(), _Detail()]); + }); + + testWidgets('pops the screen when nothing else claims the gesture', ( + tester, + ) async { + final router = KaiselRouter<_R>(initial: const _Home()); + final delegate = KaiselRouterDelegate<_R>( + router: router, + builder: (context, route) => switch (route) { + _Home() => const Scaffold(body: Center(child: Text('home'))), + _Detail() => Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => context.maybePop(), + child: const Text('maybe-pop'), + ), + ), + ), + }, + ); + await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate)); + + await router.push(const _Detail()); + await tester.pumpAndSettle(); + await tester.tap(find.text('maybe-pop')); + await tester.pumpAndSettle(); + + expect(router.stack, const [_Home()]); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('unwinds an adaptive layout that collapsed the visible pages', ( + tester, + ) async { + final router = KaiselRouter<_R>(initial: const _Home()); + final delegate = KaiselRouterDelegate<_R>.adaptive( + router: router, + builder: (context, route, stack) => switch ((route, stack.previous)) { + (_Detail(), _Home()) => KaiselAbsorbingPage( + absorbing: 1, + widget: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => context.maybePop(), + child: const Text('maybe-pop'), + ), + ), + ), + ), + _ => const KaiselStandalonePage( + Scaffold(body: Center(child: Text('home'))), + ), + }, + ); + await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate)); + + await router.push(const _Detail()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('maybe-pop')); + await tester.pumpAndSettle(); + + expect(router.stack, const [_Home()]); + }); +} diff --git a/skills/kaisel/NAVIGATION.md b/skills/kaisel/NAVIGATION.md index 3d1155d..77666a7 100644 --- a/skills/kaisel/NAVIGATION.md +++ b/skills/kaisel/NAVIGATION.md @@ -38,6 +38,7 @@ below show the typed form first. | `push(route)` | Adds to top | `Future` | Going forward to a new screen | | `pushForResult(route)` | Adds to top | `Future` (screen result) | A main-stack screen that returns a value | | `pop([result])` | Removes top | `Future` (success) | Going back; respects guards, optionally returns `result` | +| `maybePop([result])` | Navigator-first pop | `Future` (handled) | Back buttons and any screen with a drawer, sheet, or `PopScope` | | `back()` / `historyGo(delta)` | History-aligned back | `Future` (navigated) | Browser Back/Forward should mirror multi-level back on the web | | `replaceTop(route)` | Removes top, pushes new | `Future` | Swap current screen in place (no back history) | | `pushOrReplaceTop(route)` | Push if top differs in runtime type; replace if same | `Future` | Adaptive master-detail; tab-style in-place updates | @@ -156,6 +157,40 @@ a screen opened with `pushForResult`. - A guard can prevent a pop (e.g., a form-dirty guard that asks "discard changes?"). Always check the boolean if you care. +## maybePop + +```dart +context.maybePop(); +``` + +Pops the way the system back button does: the `Navigator` is asked first, +and the kaisel stack is only touched when the `Navigator` has nothing to +pop. + +**Use for:** app-bar back buttons and any "go back" affordance on a screen +that can host something dismissible. Two things get consulted that [pop] +skips: + +- **Local history entries.** A `Drawer` or `PopupMenuButton` registers a + `LocalHistoryEntry` on the enclosing route rather than pushing a route. + `maybePop` closes those first; `pop` would leave the drawer open and + remove the screen underneath it. +- **`PopScope` vetoes.** A `canPop: false` scope stops the pop and receives + `onPopInvokedWithResult`, so "intercept and redirect" handlers — step back + inside a wizard, prompt about unsaved work, complete with a value — still + run. `pop` mutates the stack without asking. + +**Notes:** + +- The returned `bool` is *handled*, not *popped* — matching + `NavigatorState.maybePop`. A veto returns `true` (the gesture was claimed); + `false` means nothing handled it and a back gesture should bubble to the OS. +- When the `Navigator` has nothing to pop, the nearest router is popped + instead, so an adaptive layout that absorbs several entries into one + visible page still unwinds. +- Coming from auto_route, this is the translation for `maybePop()` — not + `pop()`. Guards run on the resulting stack change either way. + ## back / historyGo ```dart