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
4 changes: 4 additions & 0 deletions packages/kaisel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- `reevaluateOn` on `KaiselRouterConfig` / `KaiselRouterDelegate`: point it at
any `Listenable` and the guard pipeline re-runs against the current stack
when it fires — the declarative form of `KaiselRouter.reevaluate()`
([#60](https://github.com/Mastersam07/kaisel/issues/60)).
- `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
Expand Down
4 changes: 4 additions & 0 deletions packages/kaisel/lib/src/kaisel_router_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
KaiselModalBuilder? modalBuilder,
KaiselObserversBuilder? observers,
KaiselScreenCallback? onScreenChanged,
Listenable? reevaluateOn,
GlobalKey<NavigatorState>? navigatorKey,
String? restorationScopeId,
KaiselRouteRestorer<R>? restoreRoute,
Expand All @@ -88,6 +89,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
modalBuilder: modalBuilder,
observers: observers,
onScreenChanged: onScreenChanged,
reevaluateOn: reevaluateOn,
navigatorKey: navigatorKey,
restorationScopeId: restorationScopeId,
restoreRoute: restoreRoute,
Expand All @@ -112,6 +114,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
KaiselModalBuilder? modalBuilder,
KaiselObserversBuilder? observers,
KaiselScreenCallback? onScreenChanged,
Listenable? reevaluateOn,
GlobalKey<NavigatorState>? navigatorKey,
String? restorationScopeId,
KaiselRouteRestorer<R>? restoreRoute,
Expand All @@ -134,6 +137,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
modalBuilder: modalBuilder,
observers: observers,
onScreenChanged: onScreenChanged,
reevaluateOn: reevaluateOn,
navigatorKey: navigatorKey,
restorationScopeId: restorationScopeId,
restoreRoute: restoreRoute,
Expand Down
30 changes: 30 additions & 0 deletions packages/kaisel/lib/src/kaisel_router_delegate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
this.modalBuilder,
this.observers,
this.onScreenChanged,
this.reevaluateOn,
this.restorationScopeId,
this.restoreRoute,
this.webTransition = KaiselWebTransition.fade,
Expand All @@ -141,6 +142,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>(),
_codec = codec {
router.addListener(_onRootChanged);
reevaluateOn?.addListener(_reevaluate);
_registerWithInspector();
}

Expand Down Expand Up @@ -186,6 +188,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
this.modalBuilder,
this.observers,
this.onScreenChanged,
this.reevaluateOn,
this.restorationScopeId,
this.restoreRoute,
this.webTransition = KaiselWebTransition.fade,
Expand All @@ -197,6 +200,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>(),
_codec = codec {
router.addListener(_onRootChanged);
reevaluateOn?.addListener(_reevaluate);
_registerWithInspector();
}

Expand Down Expand Up @@ -240,6 +244,31 @@ class KaiselRouterDelegate<R extends KaiselRoute>
/// predictive animation shows there; Android 12 and below never engage it.
final bool androidPredictiveBack;

/// Re-runs the guard pipeline against the current stack whenever this
/// fires.
///
/// Guards run on stack mutations, so a policy that depends on state which
/// can change *while the stack sits still* — a lock timer, an expiring
/// session, a lapsing entitlement, a flipped flag — otherwise never gets
/// re-consulted. Point this at whatever already notifies on that change and
/// the guard stays the single authority:
///
/// ```dart
/// KaiselRouterConfig(
/// guards: [appLockGuard(lockState)],
/// reevaluateOn: lockState,
/// ...
/// );
/// ```
///
/// See [KaiselRouter.reevaluate] for the imperative form.
final Listenable? reevaluateOn;

void _reevaluate() {
if (_isDisposed) return;
unawaited(router.reevaluate());
}

/// Optional builder that renders an active modal flow over the main
/// UI. Required if your app uses `router.run<T>(...)`.
final KaiselModalBuilder? modalBuilder;
Expand Down Expand Up @@ -1096,6 +1125,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
KaiselInspector.instance.deregister(t);
}
router.removeListener(_onRootChanged);
reevaluateOn?.removeListener(_reevaluate);
for (final handle in _nested) {
final listener = _nestedListeners.remove(handle);
if (listener != null) handle.removeListener(listener);
Expand Down
106 changes: 106 additions & 0 deletions packages/kaisel/test/kaisel_reevaluate_on_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import 'package:flutter/foundation.dart';
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 _Vault extends _R {
const _Vault();
}

final class _Passcode extends _R {
const _Passcode();
}

KaiselGuard<_R> _lockGuard(ValueListenable<bool> locked) =>
(current, proposed) {
final protected = proposed.any((route) => route is _Vault);
final shown = proposed.any((route) => route is _Passcode);
if (protected && locked.value && !shown) {
return [...proposed, const _Passcode()];
}
if (!locked.value && shown) {
return proposed.where((route) => route is! _Passcode).toList();
}
return proposed;
};

void main() {
testWidgets('a lock firing on a timer re-runs guards on the idle stack', (
tester,
) async {
final locked = ValueNotifier<bool>(false);
addTearDown(locked.dispose);
final config = KaiselRouterConfig<_R>(
initial: const _Home(),
guards: [_lockGuard(locked)],
reevaluateOn: locked,
builder: (context, route) => switch (route) {
_Home() => const Scaffold(body: Text('home')),
_Vault() => const Scaffold(body: Text('vault')),
_Passcode() => const Scaffold(body: Text('passcode')),
},
);
await tester.pumpWidget(MaterialApp.router(routerConfig: config));
await config.router.push(const _Vault());
await tester.pumpAndSettle();
expect(find.text('vault'), findsOneWidget);

locked.value = true;
await tester.pumpAndSettle();

expect(config.router.stack, const [_Home(), _Vault(), _Passcode()]);
expect(find.text('passcode'), findsOneWidget);
});

testWidgets('unlocking removes it through the same guard', (tester) async {
final locked = ValueNotifier<bool>(true);
addTearDown(locked.dispose);
final config = KaiselRouterConfig<_R>(
initial: const _Home(),
guards: [_lockGuard(locked)],
reevaluateOn: locked,
builder: (context, route) => switch (route) {
_Home() => const Scaffold(body: Text('home')),
_Vault() => const Scaffold(body: Text('vault')),
_Passcode() => const Scaffold(body: Text('passcode')),
},
);
await tester.pumpWidget(MaterialApp.router(routerConfig: config));
await config.router.push(const _Vault());
await tester.pumpAndSettle();
expect(config.router.stack.last, const _Passcode());

locked.value = false;
await tester.pumpAndSettle();

expect(config.router.stack, const [_Home(), _Vault()]);
});

testWidgets('the subscription is dropped when the delegate is disposed', (
tester,
) async {
final locked = ValueNotifier<bool>(false);
addTearDown(locked.dispose);
final router = KaiselRouter<_R>(initial: const _Home());
final delegate = KaiselRouterDelegate<_R>(
router: router,
reevaluateOn: locked,
builder: (context, route) => const Scaffold(body: Text('home')),
);
await tester.pumpWidget(MaterialApp.router(routerDelegate: delegate));

delegate.dispose();
locked.value = true;
await tester.pump();

expect(tester.takeException(), isNull);
});
}
5 changes: 5 additions & 0 deletions packages/kaisel_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
## Unreleased

- `KaiselRouter.reevaluate()` — re-runs the guard pipeline against the current
stack and applies the result, so a policy that changes while the stack sits
still (an app lock, an expiring session, a lapsing entitlement) stays owned
by the guard instead of being duplicated in a listener
([#60](https://github.com/Mastersam07/kaisel/issues/60)).
- `pushAndPopUntil(route, predicate:)` and `popUntilRoot()` on `KaiselRouter`,
joining the existing `popUntil` — anchor-relative unwinding as a single
guarded mutation, with the anchor off-by-one and the no-match case owned by
Expand Down
15 changes: 15 additions & 0 deletions packages/kaisel_core/lib/src/kaisel_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,21 @@ class KaiselRouter<R extends KaiselRoute> extends KaiselChangeNotifier
return _enqueueOrigin(() => _navigate(captured, replacesHistory: true));
}

/// Re-run the guard pipeline against the current stack and apply whatever
/// it returns.
///
/// Guards run on stack *mutations*, but the reason to re-check one is often
/// that the world changed while the stack sat still — a lock timer fired, a
/// session expired, an entitlement lapsed, a flag flipped. This is that
/// trigger: the pipeline sees `(current, current)`, so the same guard that
/// gates navigation can append a screen (lock the app) or drop one (unlock
/// it) without the app encoding that policy a second time.
///
/// Idempotent by construction: a guard that keeps returning the same stack
/// commits nothing. Serialized with every other mutation, so it is safe to
/// call from a listener while a navigation is in flight.
Future<void> reevaluate() => _enqueueOrigin(() => _navigate(stack));

/// Pop routes until [predicate] returns true for the top route, or
/// only one route remains on the stack. Runs through guards.
///
Expand Down
100 changes: 100 additions & 0 deletions packages/kaisel_core/test/kaisel_reevaluate_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import 'package:kaisel_core/kaisel_core.dart';
import 'package:test/test.dart';

sealed class _R extends KaiselRoute {
const _R();
}

final class _Home extends _R {
const _Home();
}

final class _Vault extends _R {
const _Vault();
}

final class _Passcode extends _R {
const _Passcode();
}

/// An app-lock guard: while locked, a passcode screen sits on top of any
/// protected destination; unlocking drops it again.
KaiselGuard<_R> _lockGuard(bool Function() locked) => (current, proposed) {
final protected = proposed.any((route) => route is _Vault);
final hasPasscode = proposed.any((route) => route is _Passcode);
if (protected && locked() && !hasPasscode)
return [...proposed, const _Passcode()];
if (!locked() && hasPasscode) {
return proposed.where((route) => route is! _Passcode).toList();
}
return proposed;
};

void main() {
test('locking mid-session appends the passcode screen', () async {
var locked = false;
final router = KaiselRouter<_R>.fromStack(
const [_Home(), _Vault()],
guards: [_lockGuard(() => locked)],
);

locked = true;
await router.reevaluate();

expect(router.stack, const [_Home(), _Vault(), _Passcode()]);
});

test('unlocking drops it again, from the same guard', () async {
var locked = true;
final router = KaiselRouter<_R>.fromStack(
const [_Home(), _Vault()],
guards: [_lockGuard(() => locked)],
);
await router.reevaluate();
expect(router.stack.last, const _Passcode());

locked = false;
await router.reevaluate();

expect(router.stack, const [_Home(), _Vault()]);
});

test('is idempotent — repeat calls commit nothing', () async {
const locked = true;
var notifications = 0;
final router = KaiselRouter<_R>.fromStack(
const [_Home(), _Vault()],
guards: [_lockGuard(() => locked)],
)..addListener(() => notifications++);

await router.reevaluate();
final afterFirst = notifications;
await router.reevaluate();
await router.reevaluate();

expect(router.stack, const [_Home(), _Vault(), _Passcode()]);
expect(notifications, afterFirst);
});

test('does nothing when no guard wants a change', () async {
final router = KaiselRouter<_R>.fromStack(const [_Home(), _Vault()]);

await router.reevaluate();

expect(router.stack, const [_Home(), _Vault()]);
});

test('serializes with an in-flight navigation', () async {
const locked = true;
final router = KaiselRouter<_R>.fromStack(
const [_Home()],
guards: [_lockGuard(() => locked)],
);

final push = router.push(const _Vault());
final reevaluate = router.reevaluate();
await Future.wait([push, reevaluate]);

expect(router.stack, const [_Home(), _Vault(), _Passcode()]);
});
}
Loading