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

Expand Down
1 change: 1 addition & 0 deletions packages/kaisel/lib/kaisel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions packages/kaisel/lib/src/kaisel_branched_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -324,6 +325,7 @@ class _KaiselBranchState<R extends KaiselRoute> extends State<KaiselBranch<R>> {
@override
Widget build(BuildContext context) {
Widget content = KaiselInnerNavigator<R>(
reportsScreen: false,
router: widget.router,
navigatorKey: _navKey,
pageBuilder: widget._pageBuilder,
Expand Down Expand Up @@ -685,6 +687,7 @@ class _KaiselBranchedShellState extends State<KaiselBranchedShell> {
late int _lastBranch;
KaiselRoute? _lastActiveTop;
KaiselObserversBuilder? _switchObserversBuilder;
KaiselScreenReporter? _screenReporter;
List<NavigatorObserver> _switchObservers = const [];

KaiselRoute? _activeTop() {
Expand All @@ -706,6 +709,7 @@ class _KaiselBranchedShellState extends State<KaiselBranchedShell> {
}
_lastBranch = branch;
_lastActiveTop = top;
_screenReporter?.reportRoute(top);
if (mounted) setState(() {});
}

Expand All @@ -723,6 +727,8 @@ class _KaiselBranchedShellState extends State<KaiselBranchedShell> {
_switchObserversBuilder = builder;
_switchObservers = builder?.call() ?? const <NavigatorObserver>[];
}
_screenReporter = KaiselObserverScope.reporterOf(context);
_screenReporter?.reportRoute(_activeTop());
}

@override
Expand Down
26 changes: 25 additions & 1 deletion packages/kaisel/lib/src/kaisel_inner_navigator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
///
Expand Down Expand Up @@ -36,6 +37,7 @@ class KaiselInnerNavigator<R extends KaiselRoute> extends StatefulWidget {
this.adaptivePageBuilder,
this.pageWrapper,
this.observers = const [],
this.reportsScreen = true,
}) : assert(
(pageBuilder == null) != (adaptivePageBuilder == null),
'Provide exactly one of pageBuilder or adaptivePageBuilder',
Expand Down Expand Up @@ -67,6 +69,13 @@ class KaiselInnerNavigator<R extends KaiselRoute> extends StatefulWidget {
/// Optional list of [NavigatorObserver]s for the inner navigator.
final List<NavigatorObserver> 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<KaiselInnerNavigator<R>> createState() =>
_KaiselInnerNavigatorState<R>();
Expand All @@ -81,6 +90,7 @@ class _KaiselInnerNavigatorState<R extends KaiselRoute>
// per-instance [KaiselInnerNavigator.observers]. Cached so the list instance
// is stable across rebuilds.
KaiselObserversBuilder? _observersBuilder;
KaiselScreenObserver? _screenObserver;
List<NavigatorObserver> _scopeObservers = const <NavigatorObserver>[];
List<NavigatorObserver> _observers = const <NavigatorObserver>[];

Expand All @@ -101,6 +111,16 @@ class _KaiselInnerNavigatorState<R extends KaiselRoute>
_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;
Expand All @@ -110,7 +130,11 @@ class _KaiselInnerNavigatorState<R extends KaiselRoute>
}

void _mergeObservers() {
_observers = <NavigatorObserver>[..._scopeObservers, ...widget.observers];
_observers = <NavigatorObserver>[
..._scopeObservers,
...widget.observers,
?_screenObserver,
];
}

void _onChange() {
Expand Down
5 changes: 5 additions & 0 deletions packages/kaisel/lib/src/kaisel_router_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,6 +65,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
bool androidPredictiveBack = false,
KaiselModalBuilder? modalBuilder,
KaiselObserversBuilder? observers,
KaiselScreenCallback? onScreenChanged,
GlobalKey<NavigatorState>? navigatorKey,
String? restorationScopeId,
KaiselRouteRestorer<R>? restoreRoute,
Expand All @@ -85,6 +87,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
androidPredictiveBack: androidPredictiveBack,
modalBuilder: modalBuilder,
observers: observers,
onScreenChanged: onScreenChanged,
navigatorKey: navigatorKey,
restorationScopeId: restorationScopeId,
restoreRoute: restoreRoute,
Expand All @@ -108,6 +111,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
bool androidPredictiveBack = false,
KaiselModalBuilder? modalBuilder,
KaiselObserversBuilder? observers,
KaiselScreenCallback? onScreenChanged,
GlobalKey<NavigatorState>? navigatorKey,
String? restorationScopeId,
KaiselRouteRestorer<R>? restoreRoute,
Expand All @@ -129,6 +133,7 @@ class KaiselRouterConfig<R extends KaiselRoute>
androidPredictiveBack: androidPredictiveBack,
modalBuilder: modalBuilder,
observers: observers,
onScreenChanged: onScreenChanged,
navigatorKey: navigatorKey,
restorationScopeId: restorationScopeId,
restoreRoute: restoreRoute,
Expand Down
41 changes: 37 additions & 4 deletions packages/kaisel/lib/src/kaisel_router_delegate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -66,24 +67,35 @@ typedef KaiselObserversBuilder = List<NavigatorObserver> 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<KaiselObserverScope>()
?.observers;

/// The nearest screen reporter, or null if none is installed.
static KaiselScreenReporter? reporterOf(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<KaiselObserverScope>()
?.screenReporter;

@override
bool updateShouldNotify(KaiselObserverScope oldWidget) =>
!identical(oldWidget.observers, observers);
!identical(oldWidget.observers, observers) ||
!identical(oldWidget.screenReporter, screenReporter);
}

/// The renderer over a [KaiselRouter].
Expand Down Expand Up @@ -117,6 +129,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
this.pageWrapper,
this.modalBuilder,
this.observers,
this.onScreenChanged,
this.restorationScopeId,
this.restoreRoute,
this.webTransition = KaiselWebTransition.fade,
Expand Down Expand Up @@ -172,6 +185,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
this.pageWrapper,
this.modalBuilder,
this.observers,
this.onScreenChanged,
this.restorationScopeId,
this.restoreRoute,
this.webTransition = KaiselWebTransition.fade,
Expand Down Expand Up @@ -248,10 +262,28 @@ class KaiselRouterDelegate<R extends KaiselRoute>
/// `restorationScopeId` (e.g. on `MaterialApp`).
final KaiselRouteRestorer<R>? 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<NavigatorObserver> _mainObservers =
observers?.call() ?? const <NavigatorObserver>[];
late final List<NavigatorObserver> _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<NavigatorState>`); defaults
Expand Down Expand Up @@ -458,6 +490,7 @@ class KaiselRouterDelegate<R extends KaiselRoute>
androidPredictiveBack: androidPredictiveBack,
child: KaiselObserverScope(
observers: observers,
screenReporter: _screenReporter,
child: KaiselNestedHostScope(
host: this,
child: RouterScope<R>(
Expand Down
85 changes: 85 additions & 0 deletions packages/kaisel/lib/src/kaisel_screen_signal.dart
Original file line number Diff line number Diff line change
@@ -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<dynamic>? 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<dynamic> route, Route<dynamic>? previousRoute) =>
reporter.report(route);

@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) =>
reporter.report(previousRoute);

@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) =>
reporter.report(previousRoute);

@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) =>
reporter.report(newRoute);
}
5 changes: 5 additions & 0 deletions packages/kaisel/lib/src/kaisel_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -192,6 +193,7 @@ class _KaiselShellState<R extends KaiselRoute> extends State<KaiselShell<R>> {
late int _lastBranch;
KaiselRoute? _lastActiveTop;
KaiselObserversBuilder? _switchObserversBuilder;
KaiselScreenReporter? _screenReporter;
List<NavigatorObserver> _switchObservers = const [];

KaiselRoute? _activeTop() {
Expand All @@ -213,6 +215,7 @@ class _KaiselShellState<R extends KaiselRoute> extends State<KaiselShell<R>> {
}
_lastBranch = branch;
_lastActiveTop = top;
_screenReporter?.reportRoute(top);
if (mounted) setState(() {});
}

Expand All @@ -224,6 +227,8 @@ class _KaiselShellState<R extends KaiselRoute> extends State<KaiselShell<R>> {
_switchObserversBuilder = builder;
_switchObservers = builder?.call() ?? const <NavigatorObserver>[];
}
_screenReporter = KaiselObserverScope.reporterOf(context);
_screenReporter?.reportRoute(_activeTop());
}

@override
Expand Down
Loading
Loading