Skip to content
Merged
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
12 changes: 10 additions & 2 deletions packages/kaisel/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions packages/kaisel/lib/src/kaisel_scope.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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.
///
Expand Down
155 changes: 155 additions & 0 deletions packages/kaisel/test/kaisel_maybe_pop_test.dart
Original file line number Diff line number Diff line change
@@ -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<Object?>(
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()]);
});
}
35 changes: 35 additions & 0 deletions skills/kaisel/NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ below show the typed form first.
| `push(route)` | Adds to top | `Future<void>` | Going forward to a new screen |
| `pushForResult<T>(route)` | Adds to top | `Future<T?>` (screen result) | A main-stack screen that returns a value |
| `pop([result])` | Removes top | `Future<bool>` (success) | Going back; respects guards, optionally returns `result` |
| `maybePop([result])` | Navigator-first pop | `Future<bool>` (handled) | Back buttons and any screen with a drawer, sheet, or `PopScope` |
| `back()` / `historyGo(delta)` | History-aligned back | `Future<bool>` (navigated) | Browser Back/Forward should mirror multi-level back on the web |
| `replaceTop(route)` | Removes top, pushes new | `Future<void>` | Swap current screen in place (no back history) |
| `pushOrReplaceTop(route)` | Push if top differs in runtime type; replace if same | `Future<void>` | Adaptive master-detail; tab-style in-place updates |
Expand Down Expand Up @@ -156,6 +157,40 @@ a screen opened with `pushForResult<T>`.
- 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
Expand Down
Loading