From 8dbc50ebd44218e3df63be02545b8cd403fdd2e8 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Wed, 26 Aug 2026 07:10:17 +0100 Subject: [PATCH] feat: add onScreenChanged, an app-level visible-screen signal --- packages/kaisel/CHANGELOG.md | 12 +- packages/kaisel/lib/kaisel.dart | 1 + .../kaisel/lib/src/kaisel_branched_shell.dart | 6 + .../lib/src/kaisel_inner_navigator.dart | 26 +++- .../kaisel/lib/src/kaisel_router_config.dart | 5 + .../lib/src/kaisel_router_delegate.dart | 41 +++++- .../kaisel/lib/src/kaisel_screen_signal.dart | 85 ++++++++++++ packages/kaisel/lib/src/kaisel_shell.dart | 5 + .../test/kaisel_screen_changed_test.dart | 128 ++++++++++++++++++ site/src/content/docs/how-to/track-screens.md | 20 +++ skills/kaisel/SHELLS.md | 6 + 11 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 packages/kaisel/lib/src/kaisel_screen_signal.dart create mode 100644 packages/kaisel/test/kaisel_screen_changed_test.dart diff --git a/packages/kaisel/CHANGELOG.md b/packages/kaisel/CHANGELOG.md index 571f2a3..06bce72 100644 --- a/packages/kaisel/CHANGELOG.md +++ b/packages/kaisel/CHANGELOG.md @@ -1,3 +1,13 @@ +# Changelog + +## Unreleased + +- `onScreenChanged` on `KaiselRouterConfig` / `KaiselRouterDelegate`: one + app-level signal for "the visible screen changed", de-duplicated across the + main stack, shell branches, modules, and flows — so screen-view analytics + no longer re-logs a tab you return to + ([#66](https://github.com/Mastersam07/kaisel/issues/66)). + ## 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/kaisel.dart b/packages/kaisel/lib/kaisel.dart index dc19896..bbf299b 100644 --- a/packages/kaisel/lib/kaisel.dart +++ b/packages/kaisel/lib/kaisel.dart @@ -43,6 +43,7 @@ export 'src/kaisel_router_delegate.dart' KaiselPageBuilder, KaiselRouterDelegate; export 'src/kaisel_scope.dart'; +export 'src/kaisel_screen_signal.dart' show KaiselScreenCallback; export 'src/kaisel_stack_restorer.dart' show KaiselRouteRestorer; export 'src/kaisel_shell.dart' show KaiselBranchScope, KaiselShell, KaiselShellChromeBuilder, ShellRouter; diff --git a/packages/kaisel/lib/src/kaisel_branched_shell.dart b/packages/kaisel/lib/src/kaisel_branched_shell.dart index 95a4c50..0e7de3f 100644 --- a/packages/kaisel/lib/src/kaisel_branched_shell.dart +++ b/packages/kaisel/lib/src/kaisel_branched_shell.dart @@ -5,6 +5,7 @@ import 'kaisel_adaptive.dart'; import 'kaisel_inner_navigator.dart'; import 'kaisel_page_wrapper.dart'; import 'kaisel_router_delegate.dart'; +import 'kaisel_screen_signal.dart'; import 'kaisel_scope.dart'; /// Aggregator for a shell whose branches have **different** route types. @@ -324,6 +325,7 @@ class _KaiselBranchState extends State> { @override Widget build(BuildContext context) { Widget content = KaiselInnerNavigator( + reportsScreen: false, router: widget.router, navigatorKey: _navKey, pageBuilder: widget._pageBuilder, @@ -685,6 +687,7 @@ class _KaiselBranchedShellState extends State { late int _lastBranch; KaiselRoute? _lastActiveTop; KaiselObserversBuilder? _switchObserversBuilder; + KaiselScreenReporter? _screenReporter; List _switchObservers = const []; KaiselRoute? _activeTop() { @@ -706,6 +709,7 @@ class _KaiselBranchedShellState extends State { } _lastBranch = branch; _lastActiveTop = top; + _screenReporter?.reportRoute(top); if (mounted) setState(() {}); } @@ -723,6 +727,8 @@ class _KaiselBranchedShellState extends State { _switchObserversBuilder = builder; _switchObservers = builder?.call() ?? const []; } + _screenReporter = KaiselObserverScope.reporterOf(context); + _screenReporter?.reportRoute(_activeTop()); } @override diff --git a/packages/kaisel/lib/src/kaisel_inner_navigator.dart b/packages/kaisel/lib/src/kaisel_inner_navigator.dart index 722a84d..8b60d1c 100644 --- a/packages/kaisel/lib/src/kaisel_inner_navigator.dart +++ b/packages/kaisel/lib/src/kaisel_inner_navigator.dart @@ -7,6 +7,7 @@ import 'kaisel_default_page.dart'; import 'kaisel_page_scope.dart'; import 'kaisel_page_wrapper.dart'; import 'kaisel_router_delegate.dart'; +import 'kaisel_screen_signal.dart'; /// A [Navigator] driven by a [KaiselRouter]. /// @@ -36,6 +37,7 @@ class KaiselInnerNavigator extends StatefulWidget { this.adaptivePageBuilder, this.pageWrapper, this.observers = const [], + this.reportsScreen = true, }) : assert( (pageBuilder == null) != (adaptivePageBuilder == null), 'Provide exactly one of pageBuilder or adaptivePageBuilder', @@ -67,6 +69,13 @@ class KaiselInnerNavigator extends StatefulWidget { /// Optional list of [NavigatorObserver]s for the inner navigator. final List observers; + /// Whether this navigator feeds the app-level `onScreenChanged` signal. + /// + /// False for a shell's branch navigators: every branch mounts and pushes its + /// root, but only one is on screen, so the shell reports the active branch's + /// top instead. + final bool reportsScreen; + @override State> createState() => _KaiselInnerNavigatorState(); @@ -81,6 +90,7 @@ class _KaiselInnerNavigatorState // per-instance [KaiselInnerNavigator.observers]. Cached so the list instance // is stable across rebuilds. KaiselObserversBuilder? _observersBuilder; + KaiselScreenObserver? _screenObserver; List _scopeObservers = const []; List _observers = const []; @@ -101,6 +111,16 @@ class _KaiselInnerNavigatorState _androidPredictiveBack = KaiselWebTransitionScope.androidPredictiveBackOf( context, ); + final reporter = widget.reportsScreen + ? KaiselObserverScope.reporterOf(context) + : null; + if (!identical(reporter, _screenObserver?.reporter)) { + _screenObserver = switch (reporter) { + final reporter? => KaiselScreenObserver(reporter), + _ => null, + }; + _mergeObservers(); + } final builder = KaiselObserverScope.of(context); if (!identical(builder, _observersBuilder)) { _observersBuilder = builder; @@ -110,7 +130,11 @@ class _KaiselInnerNavigatorState } void _mergeObservers() { - _observers = [..._scopeObservers, ...widget.observers]; + _observers = [ + ..._scopeObservers, + ...widget.observers, + ?_screenObserver, + ]; } void _onChange() { diff --git a/packages/kaisel/lib/src/kaisel_router_config.dart b/packages/kaisel/lib/src/kaisel_router_config.dart index 455fe18..84a299b 100644 --- a/packages/kaisel/lib/src/kaisel_router_config.dart +++ b/packages/kaisel/lib/src/kaisel_router_config.dart @@ -6,6 +6,7 @@ import 'kaisel_default_page.dart'; import 'kaisel_page_wrapper.dart'; import 'kaisel_route_information_parser.dart'; import 'kaisel_router_delegate.dart'; +import 'kaisel_screen_signal.dart'; import 'kaisel_stack_restorer.dart'; /// A ready-made [RouterConfig] that bundles a [KaiselRouter] and a @@ -64,6 +65,7 @@ class KaiselRouterConfig bool androidPredictiveBack = false, KaiselModalBuilder? modalBuilder, KaiselObserversBuilder? observers, + KaiselScreenCallback? onScreenChanged, GlobalKey? navigatorKey, String? restorationScopeId, KaiselRouteRestorer? restoreRoute, @@ -85,6 +87,7 @@ class KaiselRouterConfig androidPredictiveBack: androidPredictiveBack, modalBuilder: modalBuilder, observers: observers, + onScreenChanged: onScreenChanged, navigatorKey: navigatorKey, restorationScopeId: restorationScopeId, restoreRoute: restoreRoute, @@ -108,6 +111,7 @@ class KaiselRouterConfig bool androidPredictiveBack = false, KaiselModalBuilder? modalBuilder, KaiselObserversBuilder? observers, + KaiselScreenCallback? onScreenChanged, GlobalKey? navigatorKey, String? restorationScopeId, KaiselRouteRestorer? restoreRoute, @@ -129,6 +133,7 @@ class KaiselRouterConfig androidPredictiveBack: androidPredictiveBack, modalBuilder: modalBuilder, observers: observers, + onScreenChanged: onScreenChanged, 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 25e5f12..f783e76 100644 --- a/packages/kaisel/lib/src/kaisel_router_delegate.dart +++ b/packages/kaisel/lib/src/kaisel_router_delegate.dart @@ -10,6 +10,7 @@ import 'kaisel_default_page.dart'; import 'kaisel_inner_navigator.dart'; import 'kaisel_page_scope.dart'; import 'kaisel_page_wrapper.dart'; +import 'kaisel_screen_signal.dart'; import 'kaisel_scope.dart'; import 'kaisel_stack_restorer.dart'; @@ -66,24 +67,35 @@ typedef KaiselObserversBuilder = List Function(); /// [KaiselRouterDelegate] so that nested navigators (shell branches, modules, /// flows) can attach their own fresh observers. You don't use this directly. class KaiselObserverScope extends InheritedWidget { - /// Create the scope with the app's [observers] builder. + /// Create the scope with the app's [observers] builder and, when the app + /// asked for one, the [screenReporter] every visible navigator feeds. const KaiselObserverScope({ super.key, required this.observers, + this.screenReporter, required super.child, }); /// The builder, or null when the app supplied no observers. final KaiselObserversBuilder? observers; + /// The app-level screen signal, or null when no `onScreenChanged` was given. + final KaiselScreenReporter? screenReporter; + /// The nearest builder, or null if none is installed. static KaiselObserversBuilder? of(BuildContext context) => context .dependOnInheritedWidgetOfExactType() ?.observers; + /// The nearest screen reporter, or null if none is installed. + static KaiselScreenReporter? reporterOf(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.screenReporter; + @override bool updateShouldNotify(KaiselObserverScope oldWidget) => - !identical(oldWidget.observers, observers); + !identical(oldWidget.observers, observers) || + !identical(oldWidget.screenReporter, screenReporter); } /// The renderer over a [KaiselRouter]. @@ -117,6 +129,7 @@ class KaiselRouterDelegate this.pageWrapper, this.modalBuilder, this.observers, + this.onScreenChanged, this.restorationScopeId, this.restoreRoute, this.webTransition = KaiselWebTransition.fade, @@ -172,6 +185,7 @@ class KaiselRouterDelegate this.pageWrapper, this.modalBuilder, this.observers, + this.onScreenChanged, this.restorationScopeId, this.restoreRoute, this.webTransition = KaiselWebTransition.fade, @@ -248,10 +262,28 @@ class KaiselRouterDelegate /// `restorationScopeId` (e.g. on `MaterialApp`). final KaiselRouteRestorer? restoreRoute; + /// Called with the route the user is now looking at, once per change, + /// wherever in the app it lives — the main stack, a shell branch, a module, + /// or a modal flow. + /// + /// Unlike an [observers] instance, which belongs to one [Navigator] and so + /// holds per-branch state, this is a single app-level signal: switching tab + /// A → B → A reports each screen once, in order. Reach for it for + /// screen-view analytics; reach for [observers] when a package expects a + /// real [NavigatorObserver]. + final KaiselScreenCallback? onScreenChanged; + + late final KaiselScreenReporter? _screenReporter = switch (onScreenChanged) { + final callback? => KaiselScreenReporter(callback), + _ => null, + }; + /// The main stack's observers, built once from [observers] and reused for the /// delegate's lifetime (so they aren't rebuilt every frame). - late final List _mainObservers = - observers?.call() ?? const []; + late final List _mainObservers = [ + ...?observers?.call(), + if (_screenReporter case final reporter?) KaiselScreenObserver(reporter), + ]; /// Key for the main [Navigator]. Pass one to reach the navigator imperatively /// (e.g. a third-party SDK that wants a `GlobalKey`); defaults @@ -458,6 +490,7 @@ class KaiselRouterDelegate androidPredictiveBack: androidPredictiveBack, child: KaiselObserverScope( observers: observers, + screenReporter: _screenReporter, child: KaiselNestedHostScope( host: this, child: RouterScope( diff --git a/packages/kaisel/lib/src/kaisel_screen_signal.dart b/packages/kaisel/lib/src/kaisel_screen_signal.dart new file mode 100644 index 0000000..0a06e83 --- /dev/null +++ b/packages/kaisel/lib/src/kaisel_screen_signal.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:kaisel_core/kaisel_core.dart'; + +/// Signature for [KaiselRouterDelegate.onScreenChanged]: called with the route +/// the user is now looking at, once per change, wherever in the app it lives. +typedef KaiselScreenCallback = void Function(KaiselRoute route); + +/// Collapses the per-navigator route events of a whole app into one +/// "the visible screen changed" signal. +/// +/// A shell app has one [Navigator] per branch plus the main stack, so an +/// observer registered per navigator holds per-branch state — switching +/// A → B → A re-reports A to the instance that never saw B. This reporter is +/// app-level, so its de-duplication spans every navigator. +/// +/// Reports are coalesced with a microtask, which drains once the current frame +/// (or notification batch) finishes. Nested navigators mount and report after +/// their host, so the innermost screen wins and the route hosting a shell is +/// never reported as a screen of its own. +class KaiselScreenReporter { + /// Create a reporter that forwards changes to [onScreenChanged]. + KaiselScreenReporter(this.onScreenChanged); + + /// The app's callback. + final KaiselScreenCallback onScreenChanged; + + KaiselRoute? _reported; + KaiselRoute? _pending; + bool _scheduled = false; + + /// Record [route] as the currently visible one. Non-kaisel routes (dialogs, + /// sheets, and anything else pushed imperatively) are ignored. + void report(Route? route) { + if (route?.settings.arguments case final KaiselRoute route) { + reportRoute(route); + } + } + + /// Record [route] as the currently visible one. Used by shells, which know + /// which branch is on screen when several are mounted. + void reportRoute(KaiselRoute? route) { + if (route == null) return; + _pending = route; + if (_scheduled) return; + _scheduled = true; + scheduleMicrotask(_flush); + } + + void _flush() { + _scheduled = false; + final next = _pending; + _pending = null; + if (next == null || next == _reported) return; + _reported = next; + onScreenChanged(next); + } +} + +/// The observer kaisel attaches to every navigator it builds, feeding one +/// shared [KaiselScreenReporter]. +class KaiselScreenObserver extends NavigatorObserver { + /// Create an observer reporting to [reporter]. + KaiselScreenObserver(this.reporter); + + /// The app-level sink this observer feeds. + final KaiselScreenReporter reporter; + + @override + void didPush(Route route, Route? previousRoute) => + reporter.report(route); + + @override + void didPop(Route route, Route? previousRoute) => + reporter.report(previousRoute); + + @override + void didRemove(Route route, Route? previousRoute) => + reporter.report(previousRoute); + + @override + void didReplace({Route? newRoute, Route? oldRoute}) => + reporter.report(newRoute); +} diff --git a/packages/kaisel/lib/src/kaisel_shell.dart b/packages/kaisel/lib/src/kaisel_shell.dart index ed303cf..550386a 100644 --- a/packages/kaisel/lib/src/kaisel_shell.dart +++ b/packages/kaisel/lib/src/kaisel_shell.dart @@ -5,6 +5,7 @@ import 'kaisel_adaptive.dart'; import 'kaisel_inner_navigator.dart'; import 'kaisel_page_wrapper.dart'; import 'kaisel_router_delegate.dart'; +import 'kaisel_screen_signal.dart'; import 'kaisel_scope.dart'; /// A multi-branch navigation state container. @@ -192,6 +193,7 @@ class _KaiselShellState extends State> { late int _lastBranch; KaiselRoute? _lastActiveTop; KaiselObserversBuilder? _switchObserversBuilder; + KaiselScreenReporter? _screenReporter; List _switchObservers = const []; KaiselRoute? _activeTop() { @@ -213,6 +215,7 @@ class _KaiselShellState extends State> { } _lastBranch = branch; _lastActiveTop = top; + _screenReporter?.reportRoute(top); if (mounted) setState(() {}); } @@ -224,6 +227,8 @@ class _KaiselShellState extends State> { _switchObserversBuilder = builder; _switchObservers = builder?.call() ?? const []; } + _screenReporter = KaiselObserverScope.reporterOf(context); + _screenReporter?.reportRoute(_activeTop()); } @override diff --git a/packages/kaisel/test/kaisel_screen_changed_test.dart b/packages/kaisel/test/kaisel_screen_changed_test.dart new file mode 100644 index 0000000..415f16c --- /dev/null +++ b/packages/kaisel/test/kaisel_screen_changed_test.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kaisel/kaisel.dart'; + +sealed class _App extends KaiselRoute { + const _App(); +} + +final class _MainShell extends _App { + const _MainShell(); +} + +final class _Settings extends _App { + const _Settings(); +} + +sealed class _HomeRoute extends KaiselRoute { + const _HomeRoute(); +} + +final class _HomeRoot extends _HomeRoute { + const _HomeRoot(); +} + +final class _HomeDetail extends _HomeRoute { + const _HomeDetail(); +} + +sealed class _OtherRoute extends KaiselRoute { + const _OtherRoute(); +} + +final class _OtherRoot extends _OtherRoute { + const _OtherRoot(); +} + +void main() { + late List screens; + late BranchedShellRouter shell; + + KaiselRouterConfig<_App> configFor() => KaiselRouterConfig<_App>( + initial: const _MainShell(), + onScreenChanged: (route) => screens.add(route.routeName), + builder: (context, route) => switch (route) { + _Settings() => const Scaffold(body: Text('settings')), + _MainShell() => KaiselBranchedShell.specs( + branches: [ + KaiselBranchSpec<_HomeRoute>( + initial: const _HomeRoot(), + builder: (context, r) => Scaffold(body: Text('home $r')), + ), + KaiselBranchSpec<_OtherRoute>( + initial: const _OtherRoot(), + builder: (context, r) => Scaffold(body: Text('other $r')), + ), + ], + chromeBuilder: (context, active, content, switchBranch) { + shell = context.shell() as BranchedShellRouter; + return content; + }, + ), + }, + ); + + setUp(() => screens = []); + + testWidgets('reports the branch screen, not the route hosting the shell', ( + tester, + ) async { + await tester.pumpWidget(MaterialApp.router(routerConfig: configFor())); + await tester.pumpAndSettle(); + + expect(screens, ['_HomeRoot']); + }); + + testWidgets('tab A -> B -> A reports each screen once, in order', ( + tester, + ) async { + final config = configFor(); + await tester.pumpWidget(MaterialApp.router(routerConfig: config)); + await tester.pumpAndSettle(); + + shell.switchTo(1); + await tester.pumpAndSettle(); + shell.switchTo(0); + await tester.pumpAndSettle(); + + expect(screens, ['_HomeRoot', '_OtherRoot', '_HomeRoot']); + }); + + testWidgets('follows navigation inside a branch and on the main stack', ( + tester, + ) async { + final config = configFor(); + await tester.pumpWidget(MaterialApp.router(routerConfig: config)); + await tester.pumpAndSettle(); + + await shell.current.restoreStack(const [_HomeRoot(), _HomeDetail()]); + await tester.pumpAndSettle(); + await config.router.push(const _Settings()); + await tester.pumpAndSettle(); + await config.router.pop(); + await tester.pumpAndSettle(); + + expect(screens, ['_HomeRoot', '_HomeDetail', '_Settings', '_MainShell']); + }); + + testWidgets('ignores dialogs and other imperative overlays', (tester) async { + final config = configFor(); + await tester.pumpWidget(MaterialApp.router(routerConfig: config)); + await tester.pumpAndSettle(); + final before = [...screens]; + + final context = tester.element(find.byType(Scaffold).first); + unawaited( + showDialog( + context: context, + builder: (_) => const AlertDialog(content: Text('dialog')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('dialog'), findsOneWidget); + expect(screens, before); + }); +} diff --git a/site/src/content/docs/how-to/track-screens.md b/site/src/content/docs/how-to/track-screens.md index fc6e35c..da8d466 100644 --- a/site/src/content/docs/how-to/track-screens.md +++ b/site/src/content/docs/how-to/track-screens.md @@ -23,6 +23,26 @@ That's the entire integration. The builder is called once per navigator gets its own fresh observer instance — which is what `NavigatorObserver` requires — and together they see the whole app. +## One screen signal for the whole app + +An observer instance belongs to one `Navigator`, so in a shell app its +de-duplication state is per-branch: switching tab A → B → A re-logs A, +because the instance that holds "A was last" never saw B. When you want a +single stream of screen views rather than a `NavigatorObserver`, use +`onScreenChanged` — kaisel de-duplicates it app-wide: + +```dart +KaiselRouterConfig( + onScreenChanged: (route) => analytics.logScreenView(route.routeName), + ... +); +``` + +It fires once per visible-screen change wherever the screen lives — main +stack, shell branch, module, or modal flow — and never reports the route +that merely *hosts* a shell. Dialogs, sheets, and anything else pushed +imperatively are not screens and are ignored. + ## What you get that other routers don't report kaisel reports **every navigation** to your observers, including two diff --git a/skills/kaisel/SHELLS.md b/skills/kaisel/SHELLS.md index 19f72b2..e643460 100644 --- a/skills/kaisel/SHELLS.md +++ b/skills/kaisel/SHELLS.md @@ -15,6 +15,12 @@ the app has a bottom navigation bar, a sidebar, or any other "this is the persistent chrome, and the content swaps based on the selected section" pattern. +**Screen-view analytics across branches:** an observer belongs to one +navigator, so its own de-duplication is per-branch — A → B → A re-logs A. +For a single app-wide signal use `onScreenChanged` on the config, which +reports the active branch's top (never the route hosting the shell) and +de-duplicates across every navigator. + ## Quick reference | Type | Purpose |