diff --git a/packages/kaisel/CHANGELOG.md b/packages/kaisel/CHANGELOG.md index 178e5fb..4b2c5a5 100644 --- a/packages/kaisel/CHANGELOG.md +++ b/packages/kaisel/CHANGELOG.md @@ -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 diff --git a/packages/kaisel/lib/src/kaisel_router_config.dart b/packages/kaisel/lib/src/kaisel_router_config.dart index 84a299b..8c3bc0d 100644 --- a/packages/kaisel/lib/src/kaisel_router_config.dart +++ b/packages/kaisel/lib/src/kaisel_router_config.dart @@ -66,6 +66,7 @@ class KaiselRouterConfig KaiselModalBuilder? modalBuilder, KaiselObserversBuilder? observers, KaiselScreenCallback? onScreenChanged, + Listenable? reevaluateOn, GlobalKey? navigatorKey, String? restorationScopeId, KaiselRouteRestorer? restoreRoute, @@ -88,6 +89,7 @@ class KaiselRouterConfig modalBuilder: modalBuilder, observers: observers, onScreenChanged: onScreenChanged, + reevaluateOn: reevaluateOn, navigatorKey: navigatorKey, restorationScopeId: restorationScopeId, restoreRoute: restoreRoute, @@ -112,6 +114,7 @@ class KaiselRouterConfig KaiselModalBuilder? modalBuilder, KaiselObserversBuilder? observers, KaiselScreenCallback? onScreenChanged, + Listenable? reevaluateOn, GlobalKey? navigatorKey, String? restorationScopeId, KaiselRouteRestorer? restoreRoute, @@ -134,6 +137,7 @@ class KaiselRouterConfig modalBuilder: modalBuilder, observers: observers, onScreenChanged: onScreenChanged, + reevaluateOn: reevaluateOn, navigatorKey: navigatorKey, restorationScopeId: restorationScopeId, restoreRoute: restoreRoute, diff --git a/packages/kaisel/lib/src/kaisel_router_delegate.dart b/packages/kaisel/lib/src/kaisel_router_delegate.dart index f783e76..b890d0d 100644 --- a/packages/kaisel/lib/src/kaisel_router_delegate.dart +++ b/packages/kaisel/lib/src/kaisel_router_delegate.dart @@ -130,6 +130,7 @@ class KaiselRouterDelegate this.modalBuilder, this.observers, this.onScreenChanged, + this.reevaluateOn, this.restorationScopeId, this.restoreRoute, this.webTransition = KaiselWebTransition.fade, @@ -141,6 +142,7 @@ class KaiselRouterDelegate navigatorKey = navigatorKey ?? GlobalKey(), _codec = codec { router.addListener(_onRootChanged); + reevaluateOn?.addListener(_reevaluate); _registerWithInspector(); } @@ -186,6 +188,7 @@ class KaiselRouterDelegate this.modalBuilder, this.observers, this.onScreenChanged, + this.reevaluateOn, this.restorationScopeId, this.restoreRoute, this.webTransition = KaiselWebTransition.fade, @@ -197,6 +200,7 @@ class KaiselRouterDelegate navigatorKey = navigatorKey ?? GlobalKey(), _codec = codec { router.addListener(_onRootChanged); + reevaluateOn?.addListener(_reevaluate); _registerWithInspector(); } @@ -240,6 +244,31 @@ class KaiselRouterDelegate /// 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(...)`. final KaiselModalBuilder? modalBuilder; @@ -1096,6 +1125,7 @@ class KaiselRouterDelegate 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); diff --git a/packages/kaisel/test/kaisel_reevaluate_on_test.dart b/packages/kaisel/test/kaisel_reevaluate_on_test.dart new file mode 100644 index 0000000..3bb5ef8 --- /dev/null +++ b/packages/kaisel/test/kaisel_reevaluate_on_test.dart @@ -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 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(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(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(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); + }); +} diff --git a/packages/kaisel_core/CHANGELOG.md b/packages/kaisel_core/CHANGELOG.md index f33a950..286da49 100644 --- a/packages/kaisel_core/CHANGELOG.md +++ b/packages/kaisel_core/CHANGELOG.md @@ -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 diff --git a/packages/kaisel_core/lib/src/kaisel_router.dart b/packages/kaisel_core/lib/src/kaisel_router.dart index 2cf5fc8..e379a5b 100644 --- a/packages/kaisel_core/lib/src/kaisel_router.dart +++ b/packages/kaisel_core/lib/src/kaisel_router.dart @@ -467,6 +467,21 @@ class KaiselRouter 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 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. /// diff --git a/packages/kaisel_core/test/kaisel_reevaluate_test.dart b/packages/kaisel_core/test/kaisel_reevaluate_test.dart new file mode 100644 index 0000000..bf92dfc --- /dev/null +++ b/packages/kaisel_core/test/kaisel_reevaluate_test.dart @@ -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()]); + }); +}