From 0a2e3fdcf75c3baacf69d8fce4801f3e5b6b7d95 Mon Sep 17 00:00:00 2001 From: basemosama Date: Thu, 2 Apr 2026 04:39:07 +0200 Subject: [PATCH 1/4] feat: add `onInitApp` lifecycle to `PlayxBinding` and enhance `PlayxNavigation` initialization - **New `onInitApp` Lifecycle Method for `PlayxBinding`**: Added a new `onInitApp()` lifecycle method that is called once during app initialization. This allows developers to register app-level instances (repositories, datasources, services) directly from their bindings before any route lifecycle events are triggered. - `PlayxNavigation.boot()` now returns a `Future` and automatically discovers all `PlayxBinding` instances in the route tree, calling `onInitApp()` on each during initialization. - **New `ensureInitialized`**: A static `Future` getter that completes when `boot()` and all `onInitApp()` calls finish. Useful for gating startup logic (e.g., splash screens). - **New `isInitialized`**: A static `bool` getter that returns `true` after `boot()` has completed. - **New `bindings`**: A static getter that returns an unmodifiable list of all discovered `PlayxBinding` instances from the route tree. - **New `findBinding()`**: Type-safe lookup to retrieve a specific binding by its concrete type. Throws `StateError` if not found. - **New `findBindingOrNull()`**: Same as `findBinding()` but returns `null` instead of throwing if no match is found. - `PlayxNavigationBuilder` updated to handle the async boot process seamlessly. --- CHANGELOG.md | 10 ++ example/lib/splash/splash_binding.dart | 6 + lib/src/binding/playx_binding.dart | 37 ++++- lib/src/playx_navigation.dart | 134 +++++++++++++++++- lib/src/widgets/playx_navigation_builder.dart | 8 +- pubspec.yaml | 4 +- 6 files changed, 191 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b339e..a48e87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.4.0 +- **New `onInitApp` Lifecycle Method for `PlayxBinding`**: Added a new `onInitApp()` lifecycle method that is called once during app initialization. This allows developers to register app-level instances (repositories, datasources, services) directly from their bindings before any route lifecycle events are triggered. +- `PlayxNavigation.boot()` now returns a `Future` and automatically discovers all `PlayxBinding` instances in the route tree, calling `onInitApp()` on each during initialization. +- **New `ensureInitialized`**: A static `Future` getter that completes when `boot()` and all `onInitApp()` calls finish. Useful for gating startup logic (e.g., splash screens). +- **New `isInitialized`**: A static `bool` getter that returns `true` after `boot()` has completed. +- **New `bindings`**: A static getter that returns an unmodifiable list of all discovered `PlayxBinding` instances from the route tree. +- **New `findBinding()`**: Type-safe lookup to retrieve a specific binding by its concrete type. Throws `StateError` if not found. +- **New `findBindingOrNull()`**: Same as `findBinding()` but returns `null` instead of throwing if no match is found. +- `PlayxNavigationBuilder` updated to handle the async boot process seamlessly. + ## 0.3.0 - Update GoRouter to v17.0.0 diff --git a/example/lib/splash/splash_binding.dart b/example/lib/splash/splash_binding.dart index 50df8dd..920974a 100644 --- a/example/lib/splash/splash_binding.dart +++ b/example/lib/splash/splash_binding.dart @@ -2,6 +2,12 @@ import 'package:flutter/widgets.dart'; import 'package:playx_navigation/playx_navigation.dart'; class SplashBinding extends PlayxBinding { + @override + Future onInitApp() async { + // Register app-level dependencies during initialization. + print('PlayxNavigation: Splash onInitApp - Registering dependencies'); + } + @override Future onEnter(BuildContext context, GoRouterState state) async { // Handle on Enter diff --git a/lib/src/binding/playx_binding.dart b/lib/src/binding/playx_binding.dart index f6f272a..483dbb0 100644 --- a/lib/src/binding/playx_binding.dart +++ b/lib/src/binding/playx_binding.dart @@ -5,9 +5,11 @@ import 'package:playx_navigation/src/binding/playx_page_state.dart'; /// An abstract class for defining bindings associated with routes in the PlayxNavigation system. /// /// The `PlayxBinding` class provides a mechanism to execute specific actions at different -/// stages of a route's lifecycle, such as when a route is entered, re-entered, hidden, or exited. +/// stages of a route's lifecycle, such as when the app is initialized, when a route is entered, +/// re-entered, hidden, or exited. /// /// ### Use Cases: +/// - Initialize and register app-level instances (repositories, datasources, services) during app startup. /// - Perform initialization or setup when a route is entered. /// - Release resources or perform cleanup when a route is exited. /// - Handle special cases when a route is temporarily hidden or revisited. @@ -18,6 +20,11 @@ import 'package:playx_navigation/src/binding/playx_page_state.dart'; /// ```dart /// class MyRouteBinding extends PlayxBinding { /// @override +/// Future onInitApp() async { +/// // Register app-level dependencies like repositories and datasources. +/// } +/// +/// @override /// Future onEnter(BuildContext context, GoRouterState state) async { /// // Initialize resources or fetch data for the route. /// } @@ -29,9 +36,35 @@ import 'package:playx_navigation/src/binding/playx_page_state.dart'; /// } /// ``` /// -/// The associated route's lifecycle events ([onEnter], [onReEnter], [onHidden], [onExit]) +/// The associated route's lifecycle events ([onInitApp], [onEnter], [onReEnter], [onHidden], [onExit]) /// are triggered automatically by the PlayxNavigation system. abstract class PlayxBinding { + /// Called once during the application initialization phase. + /// + /// Use this method to initialize and register app-level instances of components, + /// such as repositories, datasources, services, or any other dependencies that + /// need to be available throughout the application's lifetime. + /// + /// This method is called automatically by the PlayxNavigation system during + /// initialization (e.g., when [PlayxNavigationBuilder] is first built or when + /// [PlayxNavigation.boot] is called), before any route lifecycle events are triggered. + /// + /// Unlike [onEnter], which is called each time a specific route is navigated to, + /// [onInitApp] is invoked only once at app startup, making it ideal for + /// one-time setup tasks. + /// + /// ### Example: + /// ```dart + /// @override + /// Future onInitApp() async { + /// // Register datasources + /// Get.lazyPut(() => MyDatasource()); + /// // Register repositories + /// Get.lazyPut(() => MyRepository(Get.find())); + /// } + /// ``` + Future onInitApp() async {} + /// Called when the route is entered for the first time. /// /// Use this method for initializing resources, fetching data, or setting up the UI diff --git a/lib/src/playx_navigation.dart b/lib/src/playx_navigation.dart index ce1aa1f..d06274b 100644 --- a/lib/src/playx_navigation.dart +++ b/lib/src/playx_navigation.dart @@ -1,9 +1,13 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart' as flutter_web_plugins; import 'package:go_router/go_router.dart'; import 'router/playx_router.dart'; +import 'routes/playx_route.dart'; +import 'binding/playx_binding.dart'; /// An abstract class that provides static methods for navigating within the /// application using [GoRouter]. @@ -11,11 +15,86 @@ import 'router/playx_router.dart'; /// This class needs to be initialized by calling [PlayxNavigation.boot] before /// using any of its methods. Alternatively, you can add the `PlayxNavigationBuilder` /// widget to your widget tree. +/// +/// You can await [PlayxNavigation.ensureInitialized] anywhere in your app to +/// wait for the navigation system to finish booting and all [PlayxBinding.onInitApp] +/// methods to complete. abstract class PlayxNavigation { PlayxNavigation._(); static PlayxRouter? _playxRouter; + static Completer? _initCompleter; + + static final List _bindings = []; + + /// Returns an unmodifiable list of all [PlayxBinding] instances discovered + /// in the route tree during [boot]. + /// + /// This list is populated after [boot] completes and includes every unique + /// binding attached to a [PlayxRoute] in the router's configuration. + static List get bindings => List.unmodifiable(_bindings); + + /// Finds and returns the first [PlayxBinding] of the specified type [T] + /// from the discovered bindings. + /// + /// Throws a [StateError] if no binding of type [T] is found. + /// + /// ### Example: + /// ```dart + /// final splashBinding = PlayxNavigation.findBinding(); + /// ``` + static T findBinding() { + return _bindings.firstWhere( + (binding) => binding is T, + orElse: () => throw StateError( + 'No PlayxBinding of type $T found. ' + 'Ensure a route with this binding is registered in the router.', + ), + ) as T; + } + + /// Finds and returns the first [PlayxBinding] of the specified type [T] + /// from the discovered bindings, or `null` if no match is found. + /// + /// ### Example: + /// ```dart + /// final binding = PlayxNavigation.findBindingOrNull(); + /// if (binding != null) { + /// // Use the binding. + /// } + /// ``` + static T? findBindingOrNull() { + final match = _bindings.whereType().firstOrNull; + return match; + } + + /// A [Future] that completes when [PlayxNavigation.boot] has finished, + /// including the invocation of all [PlayxBinding.onInitApp] methods. + /// + /// Use this to gate logic that depends on the navigation system being fully + /// initialized, for example in splash screens or startup flows: + /// + /// ```dart + /// await PlayxNavigation.ensureInitialized; + /// // Safe to use navigation and any dependencies registered in onInitApp. + /// ``` + /// + /// If [boot] has not been called yet, this getter returns a [Future] that + /// will complete once [boot] finishes. + /// If [boot] has already completed, the returned [Future] resolves immediately. + static Future get ensureInitialized { + _initCompleter ??= Completer(); + return _initCompleter!.future; + } + + /// Whether the navigation system has been fully initialized. + /// + /// Returns `true` after [boot] has completed (including all + /// [PlayxBinding.onInitApp] invocations). Returns `false` otherwise. + static bool get isInitialized => + _initCompleter != null && _initCompleter!.isCompleted; + /// Gets the singleton instance of [PlayxRouter]. /// /// Throws an exception if [PlayxNavigation] has not been initialized. @@ -31,8 +110,59 @@ abstract class PlayxNavigation { /// Initializes [PlayxNavigation] with the given [GoRouter]. /// /// This method must be called before using any other methods of [PlayxNavigation]. - static void boot({required GoRouter router}) { - _playxRouter = PlayxRouter(router: router); + /// + /// When called, it automatically discovers all [PlayxBinding] instances + /// attached to [PlayxRoute]s in the router's route tree and invokes their + /// [PlayxBinding.onInitApp] methods, ensuring app-level dependencies are + /// registered before any route lifecycle events are triggered. + /// + /// After boot completes, [ensureInitialized] resolves and [isInitialized] + /// returns `true`. + static Future boot({required GoRouter router}) async { + // Reset the completer and bindings for fresh initialization (supports re-boot). + _initCompleter = Completer(); + _bindings.clear(); + try { + _playxRouter = PlayxRouter(router: router); + await _initializeBindings(router); + _initCompleter!.complete(); + } catch (e, s) { + _initCompleter!.completeError(e, s); + rethrow; + } + } + + /// Recursively walks the [GoRouter] route tree, collects all unique + /// [PlayxBinding] instances, stores them, and calls [PlayxBinding.onInitApp] on each. + static Future _initializeBindings(GoRouter router) async { + final discoveredBindings = {}; + _collectBindings(router.configuration.routes, discoveredBindings); + _bindings.addAll(discoveredBindings); + for (final binding in _bindings) { + await binding.onInitApp(); + } + } + + /// Recursively collects all [PlayxBinding] instances from a list of routes. + static void _collectBindings( + List routes, + Set bindings, + ) { + for (final route in routes) { + if (route is PlayxRoute && route.binding != null) { + bindings.add(route.binding!); + } + // Recurse into sub-routes + if (route is GoRoute) { + _collectBindings(route.routes, bindings); + } else if (route is ShellRoute) { + _collectBindings(route.routes, bindings); + } else if (route is StatefulShellRoute) { + for (final branch in route.branches) { + _collectBindings(branch.routes, bindings); + } + } + } } /// Sets up navigation for web applications. diff --git a/lib/src/widgets/playx_navigation_builder.dart b/lib/src/widgets/playx_navigation_builder.dart index b4c9762..2dfc59f 100644 --- a/lib/src/widgets/playx_navigation_builder.dart +++ b/lib/src/widgets/playx_navigation_builder.dart @@ -56,9 +56,13 @@ class _PlayxNavigationBuilderState extends State { } /// Initializes the [PlayxNavigation] system and sets up route change listeners. - void setupRouter() { + /// + /// If a [GoRouter] is provided, this method calls [PlayxNavigation.boot] + /// which initializes the navigation system and invokes [PlayxBinding.onInitApp] + /// on all discovered bindings before adding the route change listener. + Future setupRouter() async { if (widget.router != null) { - PlayxNavigation.boot(router: widget.router!); + await PlayxNavigation.boot(router: widget.router!); } PlayxNavigation.addRouteChangeListener(listenToRouteChanges); } diff --git a/pubspec.yaml b/pubspec.yaml index 8cc54a8..d2fe618 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: playx_navigation description: Playx Navigation is a Flutter package that enhances app navigation with advanced features like route lifecycle management, custom transitions, and flexible configuration. -version: 0.3.0 +version: 0.4.0 homepage: https://sourcya.io repository: https://github.com/playx-flutter/playx_navigation issue_tracker: https://github.com/playx-flutter/playx_navigation/issues @@ -21,7 +21,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - go_router: ^17.0.0 + go_router: ^17.1.0 dev_dependencies: flutter_test: From fc46e64e61bf2677312d3d331bcabd65e7171e49 Mon Sep 17 00:00:00 2001 From: basemosama Date: Thu, 2 Apr 2026 05:28:20 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20Lifecycle=20Refactoring=20(Brea?= =?UTF-8?q?king=20Changes)=20-=20**Removed=20redirect=20hack**:=20Binding?= =?UTF-8?q?=20lifecycle=20methods=20(`onEnter`,=20`onReEnter`)=20are=20no?= =?UTF-8?q?=20longer=20triggered=20through=20GoRoute's=20`redirect`=20call?= =?UTF-8?q?back.=20The=20`redirect`=20parameter=20on=20`PlayxRoute`=20is?= =?UTF-8?q?=20now=20passed=20through=20directly=20for=20user-defined=20red?= =?UTF-8?q?irection=20logic=20only.=20-=20**`onEnter`=20is=20now=20fired?= =?UTF-8?q?=20from=20`PlayxPage.initState`**:=20Guarantees=20it=20fires=20?= =?UTF-8?q?exactly=20once=20when=20the=20page=20widget=20mounts.=20This=20?= =?UTF-8?q?is=20more=20reliable=20than=20the=20previous=20redirect-based?= =?UTF-8?q?=20approach.=20-=20**`onExit`=20is=20now=20fired=20from=20`Play?= =?UTF-8?q?xPage.dispose`**:=20Fires=20only=20when=20the=20page=20is=20tru?= =?UTF-8?q?ly=20removed=20from=20the=20widget=20tree.=20For=20shell=20rout?= =?UTF-8?q?es,=20this=20means=20`onExit`=20does=20NOT=20fire=20on=20branch?= =?UTF-8?q?=20switches=20(the=20widget=20stays=20alive),=20only=20when=20t?= =?UTF-8?q?he=20route=20is=20actually=20removed.=20-=20**`onHidden`=20/=20?= =?UTF-8?q?`onReEnter`=20handled=20by=20route-change=20listener**:=20These?= =?UTF-8?q?=20fire=20when=20the=20top=20route=20changes=20=E2=80=94=20cove?= =?UTF-8?q?ring=20push=20(child=20covers=20parent),=20pop=20(child=20remov?= =?UTF-8?q?ed,=20parent=20revealed),=20and=20branch=20switching.=20-=20**`?= =?UTF-8?q?wasPoppedAndReentered`=20detection=20improved**:=20Now=20uses?= =?UTF-8?q?=20path-based=20comparison=20to=20distinguish=20between=20a=20c?= =?UTF-8?q?hild=20being=20popped=20(`true`)=20and=20a=20branch=20switch=20?= =?UTF-8?q?(`false`).=20-=20**Removed=20`shouldExecuteOnExit`**:=20No=20lo?= =?UTF-8?q?nger=20needed=20since=20`PlayxPage.dispose`=20directly=20handle?= =?UTF-8?q?s=20`onExit`.=20-=20`PlayxNavigationBuilder`=20updated=20to=20h?= =?UTF-8?q?andle=20the=20async=20boot=20process=20seamlessly.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 +- README.md | 122 +++++++++++++++-- example/lib/home/home_page.dart | 16 ++- example/lib/products/products_binding.dart | 2 +- example/pubspec.lock | 18 +-- lib/src/binding/playx_binding.dart | 6 - lib/src/routes/playx_page.dart | 51 +++++-- lib/src/routes/playx_route.dart | 80 +++-------- lib/src/widgets/playx_navigation_builder.dart | 126 +++++++++++++----- 9 files changed, 299 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a48e87d..83689bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## 0.4.0 +## 1.0.0 + +### New Features - **New `onInitApp` Lifecycle Method for `PlayxBinding`**: Added a new `onInitApp()` lifecycle method that is called once during app initialization. This allows developers to register app-level instances (repositories, datasources, services) directly from their bindings before any route lifecycle events are triggered. - `PlayxNavigation.boot()` now returns a `Future` and automatically discovers all `PlayxBinding` instances in the route tree, calling `onInitApp()` on each during initialization. - **New `ensureInitialized`**: A static `Future` getter that completes when `boot()` and all `onInitApp()` calls finish. Useful for gating startup logic (e.g., splash screens). @@ -8,6 +10,14 @@ - **New `bindings`**: A static getter that returns an unmodifiable list of all discovered `PlayxBinding` instances from the route tree. - **New `findBinding()`**: Type-safe lookup to retrieve a specific binding by its concrete type. Throws `StateError` if not found. - **New `findBindingOrNull()`**: Same as `findBinding()` but returns `null` instead of throwing if no match is found. + +### Lifecycle Refactoring (Breaking Changes) +- **Removed redirect hack**: Binding lifecycle methods (`onEnter`, `onReEnter`) are no longer triggered through GoRoute's `redirect` callback. The `redirect` parameter on `PlayxRoute` is now passed through directly for user-defined redirection logic only. +- **`onEnter` is now fired from `PlayxPage.initState`**: Guarantees it fires exactly once when the page widget mounts. This is more reliable than the previous redirect-based approach. +- **`onExit` is now fired from `PlayxPage.dispose`**: Fires only when the page is truly removed from the widget tree. For shell routes, this means `onExit` does NOT fire on branch switches (the widget stays alive), only when the route is actually removed. +- **`onHidden` / `onReEnter` handled by route-change listener**: These fire when the top route changes — covering push (child covers parent), pop (child removed, parent revealed), and branch switching. +- **`wasPoppedAndReentered` detection improved**: Now uses path-based comparison to distinguish between a child being popped (`true`) and a branch switch (`false`). +- **Removed `shouldExecuteOnExit`**: No longer needed since `PlayxPage.dispose` directly handles `onExit`. - `PlayxNavigationBuilder` updated to handle the async boot process seamlessly. ## 0.3.0 diff --git a/README.md b/README.md index 60f0da7..8bff7f4 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ ## Features - **Route Bindings**: Attach custom logic to specific routes, handling lifecycle events such as entering or exiting a route. +- **App Initialization Lifecycle**: Register app-level dependencies (repositories, datasources, services) via `onInitApp` in your bindings, called automatically during boot. +- **Initialization Awaiting**: Use `PlayxNavigation.ensureInitialized` to gate startup logic until all bindings are initialized. +- **Binding Registry**: Access any registered binding by type via `PlayxNavigation.findBinding()`. - **Advanced Route Configuration**: Fine-tune the behavior of your routes with extensive configuration options, including custom transitions, modal behavior, and state management. - **Route Management**: Easily navigate to routes, replace routes, and handle navigation stacks without the need for buildcontext. - **Custom Page Transitions**: Use predefined transitions or create your own to enhance the user experience. @@ -47,10 +50,16 @@ abstract class Paths { ### Step 2: Create Your Route Bindings -Create bindings for each route to handle lifecycle events such as entering or exiting a route. This ensures that your app's logic is properly managed. +Create bindings for each route to handle lifecycle events such as app initialization, entering or exiting a route. This ensures that your app's logic is properly managed. ```dart class ProductsBinding extends PlayxBinding { + @override + Future onInitApp() async { + // Register app-level dependencies during initialization. + // This is called once when the app boots, before any route is entered. + } + @override Future onEnter(BuildContext context, GoRouterState state) async { // Initialize resources for the products page. @@ -135,7 +144,7 @@ The `PlayxNavigation` class offers a variety of static methods for managing navi Before using any navigation methods, initialize `PlayxNavigation`: ```dart -PlayxNavigation.boot(router: yourGoRouterInstance); +await PlayxNavigation.boot(router: yourGoRouterInstance); ``` Alternatively, use `PlayxNavigationBuilder` to automatically initialize the navigation system: @@ -148,7 +157,25 @@ return PlayxNavigationBuilder( routeInformationParser: router.routeInformationParser, ); }); -``` +``` + +### Awaiting Initialization + +Since `boot()` is asynchronous (it calls `onInitApp()` on all discovered bindings), you can await initialization anywhere in your app using `ensureInitialized`: + +```dart +// In a splash screen or startup flow: +await PlayxNavigation.ensureInitialized; +// All bindings are now initialized — safe to proceed. +``` + +You can also check synchronously whether initialization has completed: + +```dart +if (PlayxNavigation.isInitialized) { + // Navigation is ready. +} +``` ### Methods Overview @@ -216,29 +243,93 @@ return PlayxNavigationBuilder( - **`addRouteChangeListener(VoidCallback listener)`**: Adds a listener for route changes. - **`removeRouteChangeListener(VoidCallback listener)`**: Removes a previously added route change listener. +### Accessing Bindings + +After initialization, all discovered `PlayxBinding` instances are stored and can be accessed by type: + +- **`bindings`**: Returns an unmodifiable list of all discovered `PlayxBinding` instances. + + ```dart + final allBindings = PlayxNavigation.bindings; + ``` + +- **`findBinding()`**: Finds and returns the first binding of the specified type. Throws `StateError` if not found. + + ```dart + final productsBinding = PlayxNavigation.findBinding(); + ``` + +- **`findBindingOrNull()`**: Same as `findBinding()` but returns `null` instead of throwing. + + ```dart + final binding = PlayxNavigation.findBindingOrNull(); + if (binding != null) { + // Use the binding. + } + ``` + ## Route Bindings ### Managing Route Lifecycle with `PlayxBinding` -`PlayxBinding` is an abstract class in the PlayxNavigation package designed to manage actions during a route's lifecycle. This includes initializing resources when a route is entered, handling tasks when it's revisited, pausing actions when it's hidden, and cleaning up when it's removed from the navigation stack. +`PlayxBinding` is an abstract class in the PlayxNavigation package designed to manage actions during both the app's initialization and a route's lifecycle. This includes registering app-level dependencies at startup, initializing resources when a route is entered, handling tasks when it's revisited, pausing actions when it's hidden, and cleaning up when it's removed from the navigation stack. **Key Features:** -- **Comprehensive Lifecycle Management:** Handle route lifecycle events such as entering, re-entering, hiding, and exiting. -- **Subroute Awareness:** The `onExit` method of a main route is called only when the main route and all its subroutes are removed, ensuring effective resource management. +- **App Initialization:** Register app-level dependencies (repositories, datasources, services) via `onInitApp`, called once at startup. +- **Widget-Lifecycle Driven:** `onEnter` fires from `PlayxPage.initState` (once on mount), `onExit` fires from `PlayxPage.dispose` (only when truly removed from the tree). +- **Route-Change Driven:** `onHidden` and `onReEnter` fire from the route-change listener when the top route changes. +- **Shell Route Aware:** In `StatefulShellRoute`, branch switching fires `onHidden`/`onReEnter` (not `onExit`/`onEnter`) because the widget stays alive in its branch. +- **Binding Access:** Retrieve any binding by type via `PlayxNavigation.findBinding()` after initialization. ### Lifecycle Methods -1. **onEnter:** Triggered when the route is first entered. Use this to initialize resources or fetch data. -2. **onReEnter:** Called when revisiting a route that is still in the stack but temporarily hidden. -3. **onHidden:** Triggered when the route is hidden but not removed. Useful for pausing tasks or releasing temporary resources. -4. **onExit:** Triggered when the route is permanently removed from the stack. Use this to clean up resources or save the state. +1. **onInitApp:** Called once during `boot()`. Register app-level dependencies. Runs before any route is entered. +2. **onEnter:** Fires from `PlayxPage.initState` when the page widget first mounts. Guaranteed to fire exactly once per route entry. +3. **onHidden:** Fires when another route takes the foreground (push, branch switch) while this route stays in the stack. +4. **onReEnter:** Fires when this route returns to the foreground after being hidden. Receives `wasPoppedAndReentered`: + - `true` — a child route was popped, revealing this parent. + - `false` — a branch switch brought this route back. +5. **onExit:** Fires from `PlayxPage.dispose` when the page is truly removed from the widget tree. + +### Lifecycle Order + +``` +onInitApp() → onEnter() → onHidden() ⇌ onReEnter() → onExit() + ↑ ↑ ↑ ↑ + once at boot widget mount route changes widget dispose +``` + +### Shell Route Behavior + +``` +Branch A (Home) ↔ Branch B (Products) + +1. Enter Home → home.onEnter +2. Switch to Products → home.onHidden, products.onEnter +3. Switch back → products.onHidden, home.onReEnter(wasPoppedAndReentered: false) +``` + +``` +Parent → Child (push/pop) + +1. Enter List → list.onEnter +2. Push Details → list.onHidden, details.onEnter +3. Pop Details → details.onExit, list.onReEnter(wasPoppedAndReentered: true) +4. Navigate away → list.onExit +``` **Example:** ```dart class MyRouteBinding extends PlayxBinding { + @override + Future onInitApp() async { + // Register app-level dependencies during initialization. + // Called once at boot, before any route lifecycle events. + } + @override Future onEnter(BuildContext context, GoRouterState state) async { // Initialize resources or fetch data for the route. @@ -251,6 +342,7 @@ class MyRouteBinding extends PlayxBinding { bool wasPoppedAndReentered, ) async { // Handle special cases when the route is revisited. + // wasPoppedAndReentered: true if a child was popped, false if branch switched. } @override @@ -267,12 +359,14 @@ class MyRouteBinding extends PlayxBinding { ### Example Use Cases +- **App-Level Dependency Registration:** Register repositories, datasources, and services in `onInitApp` so they're available before any route is entered. - **Data Fetching:** Fetch required data when a route is entered for the first time. -- **Resource Cleanup:** Release heavy resources when the route is completely exited. -- **Temporary Pauses:** Pause animations or background tasks when the route is hidden. -- **Revisit Handling:** Refresh UI or state when the route is re-entered after being hidden. +- **Resource Cleanup:** Release heavy resources when the route is completely exited. For shell routes, `onExit` only fires when the route is truly removed—not on branch switches. +- **Temporary Pauses:** Pause animations or background tasks when the route is hidden (push or branch switch). +- **Revisit Handling:** Refresh UI or state when the route is re-entered. Use `wasPoppedAndReentered` to distinguish between pop and branch switch. +- **Binding Access:** Retrieve a specific binding from anywhere: `PlayxNavigation.findBinding()`. -By extending `PlayxBinding`, you can efficiently manage the lifecycle of your application's routes and ensure that resources are used optimally. +By extending `PlayxBinding`, you can efficiently manage both app-level initialization and route-specific lifecycle, ensuring that resources are used optimally. ## Configuring Routes diff --git a/example/lib/home/home_page.dart b/example/lib/home/home_page.dart index 7db4d88..230bc59 100644 --- a/example/lib/home/home_page.dart +++ b/example/lib/home/home_page.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:playx_navigation/playx_navigation.dart'; import 'package:playx_navigation_example/navigation/routes.dart'; +import '../products/product.dart'; + class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -22,7 +24,19 @@ class _HomePageState extends State { const Text('Home'), ElevatedButton( onPressed: () { - PlayxNavigation.toNamed(Routes.products); + // PlayxNavigation.toNamed(Routes.products); + final item = Product( + id: 1, + name: 'Product ', + description: 'Description of product ', + imageUrl: 'https://picsum.photos/200/300?random=', + price: 100.0, + ); + PlayxNavigation.toNamed(Routes.details, + pathParameters: { + 'id': item.id.toString(), + }, + extra: item); }, child: const Text('Products'), ), diff --git a/example/lib/products/products_binding.dart b/example/lib/products/products_binding.dart index ec90f29..cc2f2ef 100644 --- a/example/lib/products/products_binding.dart +++ b/example/lib/products/products_binding.dart @@ -1,4 +1,4 @@ -import 'package:flutter/src/widgets/framework.dart'; +import 'package:flutter/widgets.dart'; import 'package:playx_navigation/playx_navigation.dart'; class ProductsBinding extends PlayxBinding { diff --git a/example/pubspec.lock b/example/pubspec.lock index 64b42c8..635b7bb 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -84,10 +84,10 @@ packages: dependency: transitive description: name: go_router - sha256: c92d18e1fe994cb06d48aa786c46b142a5633067e8297cff6b5a3ac742620104 + sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896" url: "https://pub.dev" source: hosted - version: "17.0.0" + version: "17.1.0" leak_tracker: dependency: transitive description: @@ -148,10 +148,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" path: dependency: transitive description: @@ -166,7 +166,7 @@ packages: path: ".." relative: true source: path - version: "0.3.0" + version: "0.4.0" sky_engine: dependency: transitive description: flutter @@ -216,10 +216,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" vector_math: dependency: transitive description: @@ -237,5 +237,5 @@ packages: source: hosted version: "15.0.0" sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/lib/src/binding/playx_binding.dart b/lib/src/binding/playx_binding.dart index 483dbb0..c99e0d2 100644 --- a/lib/src/binding/playx_binding.dart +++ b/lib/src/binding/playx_binding.dart @@ -128,12 +128,6 @@ abstract class PlayxBinding { /// - [context]: The [BuildContext] of the route. Future onHidden(BuildContext context) async {} - /// Determines whether [onExit] should be executed when the route is exited. - /// - /// **Important:** This field is managed internally by the PlayxNavigation system - /// and should not be modified directly by subclasses. - bool shouldExecuteOnExit = false; - /// Represents the current state of the page within its lifecycle. /// /// - [PlayxPageState.enter]: When the route is first entered. diff --git a/lib/src/routes/playx_page.dart b/lib/src/routes/playx_page.dart index 7dd703c..b19e901 100644 --- a/lib/src/routes/playx_page.dart +++ b/lib/src/routes/playx_page.dart @@ -2,34 +2,65 @@ import 'package:flutter/widgets.dart'; import 'package:playx_navigation/playx_navigation.dart'; import 'package:playx_navigation/src/binding/playx_page_state.dart'; +/// An internal widget that wraps a route's content and manages the +/// [PlayxBinding] lifecycle via standard widget lifecycle methods. +/// +/// - [initState]: Fires [PlayxBinding.onEnter] and **blocks the child from +/// building** until it completes. This guarantees that any dependencies +/// registered in `onEnter` (e.g., GetX controllers) are available before +/// the page's `build` method runs. +/// - [dispose]: Fires [PlayxBinding.onExit] when the page is removed from the tree. +/// +/// This ensures `onEnter` fires exactly once per route entry, and `onExit` +/// fires exactly once when the route is truly removed (not just hidden in a +/// [StatefulShellRoute] branch). class PlayxPage extends StatefulWidget { final PlayxBinding binding; + final GoRouterState state; final Widget child; - const PlayxPage({super.key, required this.binding, required this.child}); + + const PlayxPage({ + super.key, + required this.binding, + required this.state, + required this.child, + }); @override State createState() => _PlayxPageState(); } class _PlayxPageState extends State { - @override - Widget build(BuildContext context) { - return widget.child; - } + bool _initialized = false; @override void initState() { super.initState(); + _initialize(); + } + + Future _initialize() async { + widget.binding.currentState = PlayxPageState.enter; + await widget.binding.onEnter(context, widget.state); + if (mounted) { + setState(() { + _initialized = true; + }); + } } @override void dispose() { + widget.binding.onExit(context); + widget.binding.currentState = PlayxPageState.exit; super.dispose(); - if (widget.binding.shouldExecuteOnExit) { - widget.binding.onExit( - context, - ); - widget.binding.currentState = PlayxPageState.exit; + } + + @override + Widget build(BuildContext context) { + if (!_initialized) { + return const SizedBox.shrink(); } + return widget.child; } } diff --git a/lib/src/routes/playx_route.dart b/lib/src/routes/playx_route.dart index 1d05261..d875e56 100644 --- a/lib/src/routes/playx_route.dart +++ b/lib/src/routes/playx_route.dart @@ -1,5 +1,4 @@ import 'package:go_router/go_router.dart'; -import 'package:playx_navigation/src/binding/playx_page_state.dart'; import 'package:playx_navigation/src/routes/playx_page.dart'; import '../binding/playx_binding.dart'; @@ -45,21 +44,27 @@ import '../models/playx_page_transition.dart'; /// - **Page Transition:** The [transition] parameter allows for custom animations when transitioning between pages. /// - **Page Configuration:** The [pageConfiguration] parameter enables customization of page settings, including title and unique key. /// - **Route Binding:** The [binding] parameter allows for attaching a [PlayxBinding] to handle lifecycle events. -/// - `onEnter`: Invoked when the route is entered for the first time. For subroutes, this method is called each time the subroute is entered. -/// - `onExit`: Called when the route is exited. For subroutes, the `onExit` method of the main route is executed only when the main route is removed from the navigation stack. +/// - `onEnter`: Invoked when the route's page widget first mounts (via `initState`). +/// - `onExit`: Called when the route's page widget is disposed (truly removed from the tree). +/// - `onHidden`: Called when another route takes the foreground while this route stays in the stack. +/// - `onReEnter`: Called when this route returns to the foreground after being hidden. /// /// **Custom Redirection:** -/// If a [redirect] callback is provided, it will handle redirection logic. If not provided, the default redirection behavior is applied. +/// If a [redirect] callback is provided, it will handle redirection logic independently of binding lifecycle. /// /// **Route Lifecycle:** -/// - The `onEnter` method of the route's binding is executed when the route is entered for the first time or when navigating to the same route as the top route. -/// - The `onExit` method is executed when the route is exited. If a [binding] is provided, its `onExit` method is called based on the `shouldExecuteOnExit` flag. +/// The binding lifecycle is managed through the [PlayxPage] widget lifecycle +/// and the [PlayxNavigationBuilder] route-change listener — no longer through redirect callbacks. class PlayxRoute extends GoRoute { /// An optional [PlayxBinding] instance used to handle route-specific lifecycle events. /// - /// The [binding] allows you to attach custom logic that is executed when the route is entered or exited. - /// - `onEnter`: This method is called when the route is entered for the first time. - /// - `onExit`: This method is called when the route is exited. + /// The [binding] allows you to attach custom logic that is executed at different + /// stages of the route's lifecycle: + /// - `onInitApp`: Called once during app initialization. + /// - `onEnter`: Called when the route's page widget first mounts. + /// - `onHidden`: Called when another route covers this route. + /// - `onReEnter`: Called when this route returns to the foreground. + /// - `onExit`: Called when the route's page widget is disposed. final PlayxBinding? binding; /// Specifies the page transition animation to be used. @@ -83,63 +88,20 @@ class PlayxRoute extends GoRoute { this.pageConfiguration = const PlayxPageConfiguration(), super.parentNavigatorKey, this.binding, - GoRouterRedirect? redirect, - ExitCallback? onExit, + super.redirect, + super.onExit, super.routes = const [], }) : super( - redirect: (context, state) async { - // Handle custom redirection logic if provided - if (redirect != null) { - return redirect(context, state); - } - if (binding == null) return null; - - final topRoute = state.topRoute; - - // If the current route is no longer on top, call onExit if needed - if (topRoute == null || topRoute.path != path) { - if (binding.shouldExecuteOnExit) { - await binding.onExit(context); - } - return null; - } - - // Trigger onEnter when entering the page for the first time and when the top route is the same as the current route - // We need to fire onEnter here so we can have access to the route state. - final pageState = binding.currentState; - final isFirstEnter = - pageState == null || pageState == PlayxPageState.exit; - binding.currentState = - isFirstEnter ? PlayxPageState.enter : PlayxPageState.reEnter; - - if (isFirstEnter) { - binding.onEnter(context, state); - } else { - binding.onReEnter( - context, - state, - false, - ); - } - binding.shouldExecuteOnExit = false; - return null; - }, - onExit: binding == null - ? onExit - : (context, state) async { - bool shouldExit = true; - if (onExit != null) { - shouldExit = await onExit(context, state); - } - binding.shouldExecuteOnExit = shouldExit; - return shouldExit; - }, pageBuilder: (ctx, state) { return transition.buildPage( config: pageConfiguration, child: binding == null ? builder(ctx, state) - : PlayxPage(binding: binding, child: builder(ctx, state)), + : PlayxPage( + binding: binding, + state: state, + child: builder(ctx, state), + ), state: state, ); }, diff --git a/lib/src/widgets/playx_navigation_builder.dart b/lib/src/widgets/playx_navigation_builder.dart index 2dfc59f..1023159 100644 --- a/lib/src/widgets/playx_navigation_builder.dart +++ b/lib/src/widgets/playx_navigation_builder.dart @@ -12,7 +12,15 @@ import '../routes/playx_route.dart'; /// [PlayxNavigation] instance and listening to route changes in the /// navigation system. It ensures that the navigation system is properly /// initialized and provides a way to execute specific logic when navigating -/// between routes. like handling [PlayxBinding] onEnter and onExit methods. +/// between routes — specifically handling [PlayxBinding.onHidden] and +/// [PlayxBinding.onReEnter] lifecycle events. +/// +/// **Lifecycle responsibilities:** +/// - `onHidden`: Fired when the current route is covered by another route +/// (push, branch switch) but remains in the navigation stack. +/// - `onReEnter`: Fired when a previously hidden route returns to the foreground +/// (pop, branch switch back). +/// - `onEnter` / `onExit`: Handled by [PlayxPage] widget lifecycle, not here. /// /// This widget is typically placed high in the widget tree to wrap the entire /// application or the part of the app that relies on navigation. @@ -47,7 +55,11 @@ class PlayxNavigationBuilder extends StatefulWidget { } class _PlayxNavigationBuilderState extends State { - GoRoute? _currentRoute; + /// Tracks the previous top route to detect transitions. + GoRoute? _previousTopRoute; + + /// Tracks the previous matched location for wasPoppedAndReentered detection. + String? _previousMatchedLocation; @override void initState() { @@ -64,54 +76,102 @@ class _PlayxNavigationBuilderState extends State { if (widget.router != null) { await PlayxNavigation.boot(router: widget.router!); } - PlayxNavigation.addRouteChangeListener(listenToRouteChanges); + PlayxNavigation.addRouteChangeListener(_onRouteChanged); } - /// Callback to listen to route changes and execute binding actions. + /// Callback invoked whenever the route changes. /// - /// This method is invoked whenever the route changes. It compares the - /// current route with the previous one, and if the previous route has a - /// binding with an `onExit` action, that action is executed before - /// updating the current route. - void listenToRouteChanges() { - final currentRoute = PlayxNavigation.currentRoute; - - final previousRoute = _currentRoute; + /// Detects transitions between routes and fires the appropriate binding + /// lifecycle methods: + /// - [PlayxBinding.onHidden] on the previous binding when it's covered. + /// - [PlayxBinding.onReEnter] on the current binding when it was previously hidden. + /// + /// Determines [wasPoppedAndReentered] by checking path relationships: + /// - If the previous location was a sub-path of the current location, + /// a child route was popped → `true`. + /// - Otherwise (branch switch, go navigation) → `false`. + void _onRouteChanged() { + final currentTopRoute = PlayxNavigation.currentRoute; + final currentState = PlayxNavigation.currentState; + final currentMatchedLocation = currentState?.matchedLocation; + + final previousTopRoute = _previousTopRoute; + + // Get bindings for previous and current top routes. + final previousBinding = + previousTopRoute is PlayxRoute ? previousTopRoute.binding : null; final currentBinding = - currentRoute is PlayxRoute ? currentRoute.binding : null; + currentTopRoute is PlayxRoute ? currentTopRoute.binding : null; - if (previousRoute is PlayxRoute) { - final binding = previousRoute.binding; + // If routes haven't changed, nothing to do. + if (previousTopRoute == currentTopRoute) { + _previousTopRoute = currentTopRoute; + _previousMatchedLocation = currentMatchedLocation; + return; + } - if (binding == null) { - _currentRoute = currentRoute; - return; - } - final state = binding.currentState; - if (state != PlayxPageState.hidden) { - binding.onHidden( - context, - ); - binding.currentState = PlayxPageState.hidden; + // --- Handle the LEAVING route (previous) --- + if (previousBinding != null && previousBinding != currentBinding) { + final state = previousBinding.currentState; + // Only fire onHidden if the binding is currently active (enter or reEnter). + // Don't fire if already hidden or exited. + if (state == PlayxPageState.enter || state == PlayxPageState.reEnter) { + previousBinding.onHidden(context); + previousBinding.currentState = PlayxPageState.hidden; } } - if (currentBinding != null) { - if (currentBinding.currentState != PlayxPageState.enter && - currentBinding.currentState != PlayxPageState.reEnter) { - final GoRouterState newState = - PlayxNavigation.goRouter.routerDelegate.state; - currentBinding.onReEnter(context, newState, true); + // --- Handle the ENTERING route (current) --- + if (currentBinding != null && currentBinding != previousBinding) { + final state = currentBinding.currentState; + // If the binding was previously hidden, it's being re-entered. + if (state == PlayxPageState.hidden) { + // Determine wasPoppedAndReentered: + // If the previous matched location is a sub-path of the current one, + // it means a child route was popped, revealing this parent route. + final wasPoppedAndReentered = _wasChildPopped( + previousLocation: _previousMatchedLocation, + currentLocation: currentMatchedLocation, + ); + + currentBinding.onReEnter( + context, + currentState, + wasPoppedAndReentered, + ); currentBinding.currentState = PlayxPageState.reEnter; } + // If the binding state is enter or null, PlayxPage.initState handles onEnter. + // We don't fire onReEnter or onEnter here in that case. } - _currentRoute = currentRoute; + _previousTopRoute = currentTopRoute; + _previousMatchedLocation = currentMatchedLocation; + } + + /// Determines if a child route was popped (backward navigation). + /// + /// Returns `true` if the [previousLocation] is a sub-path of [currentLocation], + /// meaning a child route was on top and was popped to reveal the current parent. + /// + /// Returns `false` for branch switches or unrelated navigation where neither + /// location is a prefix of the other. + bool _wasChildPopped({ + required String? previousLocation, + required String? currentLocation, + }) { + if (previousLocation == null || currentLocation == null) return false; + if (previousLocation == currentLocation) return false; + + // If the previous location starts with the current location, + // it means we navigated from a child (deeper path) back to a parent. + // e.g., /products/123 → /products + return previousLocation.startsWith(currentLocation); } @override void dispose() { - PlayxNavigation.removeRouteChangeListener(listenToRouteChanges); + PlayxNavigation.removeRouteChangeListener(_onRouteChanged); super.dispose(); } From f45df1aee6ff2905de65b7a8d4cbedcece0f6fe0 Mon Sep 17 00:00:00 2001 From: basemosama Date: Thu, 2 Apr 2026 06:02:08 +0200 Subject: [PATCH 3/4] feat: add `pending` state and deferred `onEnter` execution for backstack routes - Introduced `PlayxPageState.pending` to represent pages that are mounted in the navigation stack but not yet visible. - Updated `PlayxPage` to defer `onEnter` execution for backstack routes (e.g., during deep-linking) until they become the top-most route. - Added `PlayxNavigation.addRouteChangeListener` to monitor visibility changes for deferred initialization. - Refined `PlayxBinding` lifecycle to ensure `onExit` only fires if `onEnter` was previously executed. - Enhanced example app with an "Explore" tab and complex nested navigation scenarios to demonstrate visibility-aware lifecycle management. - Added `onInitApp` hooks to example bindings and updated splash screen to wait for initialization. --- example/lib/explore/explore_binding.dart | 31 ++++ .../lib/explore/explore_details_binding.dart | 30 ++++ example/lib/explore/explore_details_page.dart | 36 +++++ example/lib/explore/explore_page.dart | 97 +++++++++++++ example/lib/home/home_binding.dart | 14 +- example/lib/home/home_page.dart | 135 ++++++++++++------ example/lib/main.dart | 2 + example/lib/navigation/pages.dart | 124 ++++++++++------ example/lib/navigation/routes.dart | 12 +- .../lib/products/details/details_binding.dart | 17 +-- .../lib/products/details/details_page.dart | 8 +- example/lib/products/products_binding.dart | 20 +-- example/lib/products/products_page.dart | 2 +- example/lib/splash/splash_page.dart | 42 ++++-- lib/src/binding/playx_page_state.dart | 8 +- lib/src/routes/playx_page.dart | 71 +++++++-- 16 files changed, 505 insertions(+), 144 deletions(-) create mode 100644 example/lib/explore/explore_binding.dart create mode 100644 example/lib/explore/explore_details_binding.dart create mode 100644 example/lib/explore/explore_details_page.dart create mode 100644 example/lib/explore/explore_page.dart diff --git a/example/lib/explore/explore_binding.dart b/example/lib/explore/explore_binding.dart new file mode 100644 index 0000000..3dd5b97 --- /dev/null +++ b/example/lib/explore/explore_binding.dart @@ -0,0 +1,31 @@ +import 'package:flutter/widgets.dart'; +import 'package:playx_navigation/playx_navigation.dart'; + +class ExploreBinding extends PlayxBinding { + @override + Future onInitApp() async { + print('PlayxNavigation: ExploreBinding onInitApp'); + } + + @override + Future onEnter(BuildContext context, GoRouterState state) async { + print('PlayxNavigation: Explore onEnter'); + } + + @override + Future onReEnter(BuildContext context, GoRouterState? state, + bool wasPoppedAndReentered) async { + print( + 'PlayxNavigation: Explore onReEnter wasPoppedAndReentered:$wasPoppedAndReentered'); + } + + @override + Future onExit(BuildContext context) async { + print('PlayxNavigation: Explore onExit'); + } + + @override + Future onHidden(BuildContext context) async { + print('PlayxNavigation: Explore onHidden'); + } +} diff --git a/example/lib/explore/explore_details_binding.dart b/example/lib/explore/explore_details_binding.dart new file mode 100644 index 0000000..18a2e75 --- /dev/null +++ b/example/lib/explore/explore_details_binding.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:playx_navigation/playx_navigation.dart'; + +/// Binding for the Explore Details route — tests the case where navigating +/// to details from a DIFFERENT branch (Explore) pushes a child route +/// in a separate branch context. +class ExploreDetailsBinding extends PlayxBinding { + @override + Future onEnter(BuildContext context, GoRouterState state) async { + final id = state.pathParameters['id']; + print('PlayxNavigation: Explore Details #$id onEnter'); + } + + @override + Future onReEnter(BuildContext context, GoRouterState? state, + bool wasPoppedAndReentered) async { + print( + 'PlayxNavigation: Explore Details onReEnter wasPoppedAndReentered:$wasPoppedAndReentered'); + } + + @override + Future onExit(BuildContext context) async { + print('PlayxNavigation: Explore Details onExit'); + } + + @override + Future onHidden(BuildContext context) async { + print('PlayxNavigation: Explore Details onHidden'); + } +} diff --git a/example/lib/explore/explore_details_page.dart b/example/lib/explore/explore_details_page.dart new file mode 100644 index 0000000..c1b712d --- /dev/null +++ b/example/lib/explore/explore_details_page.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:playx_navigation_example/products/product.dart'; + +/// Explore Details page — shows details for items from the Explore tab. +class ExploreDetailsPage extends StatelessWidget { + final Product? product; + const ExploreDetailsPage({required this.product, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Explore Details')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + product?.name ?? 'Unknown', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + product?.description ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + '\$${product?.price ?? '-'}', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/explore/explore_page.dart b/example/lib/explore/explore_page.dart new file mode 100644 index 0000000..fbcea8f --- /dev/null +++ b/example/lib/explore/explore_page.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:playx_navigation/playx_navigation.dart'; +import 'package:playx_navigation_example/navigation/routes.dart'; +import 'package:playx_navigation_example/products/product.dart'; + +/// Explore page — acts as a second tab that can also navigate to product details. +/// +/// Tests: +/// - Branch switching lifecycle (Home ↔ Explore ↔ Products → onHidden/onReEnter) +/// - Cross-branch deep link: navigate from Explore to Product Details in Products branch +class ExplorePage extends StatelessWidget { + const ExplorePage({super.key}); + + @override + Widget build(BuildContext context) { + final featuredProducts = List.generate( + 5, + (i) => Product( + id: 100 + i, + name: 'Featured #${100 + i}', + description: 'A featured product from the explore tab', + imageUrl: 'https://picsum.photos/200/300?random=${100 + i}', + price: 200.0 + i * 10, + ), + ); + + return Scaffold( + appBar: AppBar(title: const Text('Explore')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // --- Test: Navigate to Explore's own details child route --- + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Featured Products (Explore Details)', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ...featuredProducts.map((item) => Card( + child: ListTile( + title: Text(item.name), + subtitle: Text('\$${item.price}'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () { + // Push Explore's own details child route + // Tests: explore.onHidden → exploreDetails.onEnter + PlayxNavigation.toNamed( + Routes.exploreDetails, + pathParameters: {'id': item.id.toString()}, + extra: item, + ); + }, + ), + )), + + const SizedBox(height: 24), + + // --- Test: Navigate to Products tab directly --- + ElevatedButton.icon( + onPressed: () { + // Tests: Branch switch → explore.onHidden, products.onEnter (or onReEnter) + PlayxNavigation.offAllNamed(Routes.products); + }, + icon: const Icon(Icons.swap_horiz), + label: const Text('Switch to Products Tab'), + ), + + const SizedBox(height: 12), + + // --- Test: Deep link to Product Details in Products branch --- + ElevatedButton.icon( + onPressed: () { + // Tests: + // - Products page mounted as backstack → deferred onEnter (pending) + // - Details page is top → Details.onEnter fires immediately + final product = Product( + id: 42, + name: 'Deep Link Product', + description: 'Navigated from Explore to Products Details', + imageUrl: 'https://picsum.photos/200/300?random=42', + price: 999.0, + ); + PlayxNavigation.toNamed( + Routes.productDetails, + pathParameters: {'id': product.id.toString()}, + extra: product, + ); + }, + icon: const Icon(Icons.link), + label: const Text('Deep Link → Product Details (cross-branch)'), + ), + ], + ), + ); + } +} diff --git a/example/lib/home/home_binding.dart b/example/lib/home/home_binding.dart index 548058b..17e86b2 100644 --- a/example/lib/home/home_binding.dart +++ b/example/lib/home/home_binding.dart @@ -2,9 +2,13 @@ import 'package:flutter/widgets.dart'; import 'package:playx_navigation/playx_navigation.dart'; class HomeBinding extends PlayxBinding { + @override + Future onInitApp() async { + print('PlayxNavigation: HomeBinding onInitApp'); + } + @override Future onEnter(BuildContext context, GoRouterState state) async { - // Handle on Enter print('PlayxNavigation: Home onEnter'); } @@ -12,20 +16,16 @@ class HomeBinding extends PlayxBinding { Future onReEnter(BuildContext context, GoRouterState? state, bool wasPoppedAndReentered) async { print( - 'PlayxNavigation: Home onReEnter isStillInNavigationStack :$wasPoppedAndReentered'); + 'PlayxNavigation: Home onReEnter wasPoppedAndReentered:$wasPoppedAndReentered'); } @override Future onExit(BuildContext context) async { - // Handle On Exit print('PlayxNavigation: Home onExit'); } @override - Future onHidden( - BuildContext context, - ) async { - // Handle On Hidden + Future onHidden(BuildContext context) async { print('PlayxNavigation: Home onHidden'); } } diff --git a/example/lib/home/home_page.dart b/example/lib/home/home_page.dart index 230bc59..9e6a358 100644 --- a/example/lib/home/home_page.dart +++ b/example/lib/home/home_page.dart @@ -1,60 +1,109 @@ import 'package:flutter/material.dart'; import 'package:playx_navigation/playx_navigation.dart'; import 'package:playx_navigation_example/navigation/routes.dart'; +import 'package:playx_navigation_example/products/product.dart'; -import '../products/product.dart'; - -class HomePage extends StatefulWidget { +/// Home page — the default tab. Has navigation actions to test various +/// lifecycle scenarios from the home context. +class HomePage extends StatelessWidget { const HomePage({super.key}); - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Home'), - ), - body: Center( - child: Column( - children: [ - const Text('Home'), - ElevatedButton( - onPressed: () { - // PlayxNavigation.toNamed(Routes.products); - final item = Product( - id: 1, - name: 'Product ', - description: 'Description of product ', - imageUrl: 'https://picsum.photos/200/300?random=', - price: 100.0, - ); - PlayxNavigation.toNamed(Routes.details, - pathParameters: { - 'id': item.id.toString(), - }, - extra: item); - }, - child: const Text('Products'), - ), - ], - ), + appBar: AppBar(title: const Text('Home')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _sectionTitle('Lifecycle Test Actions'), + const SizedBox(height: 8), + + // --- Test: Deep link to Product Details from Home --- + // This navigates to /products/:id. + // Products page mounts as backstack → onEnter DEFERRED. + // Details page is top → onEnter fires immediately. + ElevatedButton.icon( + onPressed: () { + final product = Product( + id: 1, + name: 'Product #1', + description: 'Deep linked from Home tab', + imageUrl: 'https://picsum.photos/200/300?random=1', + price: 100.0, + ); + PlayxNavigation.toNamed( + Routes.productDetails, + pathParameters: {'id': product.id.toString()}, + extra: product, + ); + }, + icon: const Icon(Icons.link), + label: const Text('Deep Link → Product Details'), + ), + _hint('Products.onEnter deferred (backstack), ' + 'Details.onEnter fires immediately'), + + const SizedBox(height: 16), + + // --- Test: Navigate to Products tab --- + // Branch switch → home.onHidden, products.onEnter (or onReEnter) + ElevatedButton.icon( + onPressed: () { + PlayxNavigation.offAllNamed(Routes.products); + }, + icon: const Icon(Icons.swap_horiz), + label: const Text('Go to Products Tab'), + ), + _hint('home.onHidden → products.onEnter/onReEnter(false)'), + + const SizedBox(height: 16), + + // --- Test: Navigate to Explore tab --- + ElevatedButton.icon( + onPressed: () { + PlayxNavigation.offAllNamed(Routes.explore); + }, + icon: const Icon(Icons.explore), + label: const Text('Go to Explore Tab'), + ), + _hint('home.onHidden → explore.onEnter/onReEnter(false)'), + + const SizedBox(height: 24), + _sectionTitle('Binding Registry'), + const SizedBox(height: 8), + + // --- Test: Binding lookup --- + ElevatedButton.icon( + onPressed: () { + final bindings = PlayxNavigation.bindings; + final msg = 'Found ${bindings.length} bindings:\n' + '${bindings.map((b) => ' • ${b.runtimeType}').join('\n')}'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg), duration: const Duration(seconds: 3)), + ); + }, + icon: const Icon(Icons.search), + label: const Text('Show All Registered Bindings'), + ), + ], ), ); } - @override - void initState() { - super.initState(); - // print('PlayxNavigation: Home onInit'); + Widget _sectionTitle(String text) { + return Text( + text, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ); } - @override - void dispose() { - super.dispose(); - // print('PlayxNavigation: Home onDispose'); + Widget _hint(String text) { + return Padding( + padding: const EdgeInsets.only(top: 4, left: 8), + child: Text( + text, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ); } } diff --git a/example/lib/main.dart b/example/lib/main.dart index c3f9bd1..fd2f98f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -3,6 +3,8 @@ import 'package:playx_navigation/playx_navigation.dart'; import 'package:playx_navigation_example/navigation/pages.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); + PlayxNavigation.setupWeb(); runApp(const MyApp()); } diff --git a/example/lib/navigation/pages.dart b/example/lib/navigation/pages.dart index 921c07d..7f6510c 100644 --- a/example/lib/navigation/pages.dart +++ b/example/lib/navigation/pages.dart @@ -1,6 +1,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:playx_navigation/playx_navigation.dart'; +import 'package:playx_navigation_example/explore/explore_binding.dart'; +import 'package:playx_navigation_example/explore/explore_details_binding.dart'; +import 'package:playx_navigation_example/explore/explore_details_page.dart'; +import 'package:playx_navigation_example/explore/explore_page.dart'; import 'package:playx_navigation_example/home/home_binding.dart'; import 'package:playx_navigation_example/home/home_page.dart'; import 'package:playx_navigation_example/navigation/routes.dart'; @@ -9,64 +13,100 @@ import 'package:playx_navigation_example/products/details/details_page.dart'; import 'package:playx_navigation_example/products/product.dart'; import 'package:playx_navigation_example/products/products_binding.dart'; import 'package:playx_navigation_example/products/products_page.dart'; +import 'package:playx_navigation_example/splash/splash_binding.dart'; import 'package:playx_navigation_example/splash/splash_page.dart'; -import '../splash/splash_binding.dart'; - +/// Router configuration that tests all PlayxBinding lifecycle scenarios: +/// +/// 1. **Splash → Shell**: onEnter/onExit for standalone route. +/// 2. **Shell tab switching**: Home ↔ Products ↔ Explore → onHidden/onReEnter(false). +/// 3. **Parent → Child push**: Products → Product Details → onHidden/onReEnter(true). +/// 4. **Explore → Explore Details**: Same as #3 but in a different branch. +/// 5. **Cross-branch deep link**: From Explore → /products/:id. Products page mounts +/// as backstack (onEnter deferred), Details is top (onEnter fires immediately). +/// 6. **Direct deep link (web)**: Launch with /products/42 → same deferred behavior. class AppPages { static final GoRouter router = GoRouter( initialLocation: Paths.splash, debugLogDiagnostics: true, routes: [ + // Standalone route: tests onEnter/onExit for a non-shell route. PlayxRoute( path: Paths.splash, name: Routes.splash, builder: (context, state) => const SplashPage(), binding: SplashBinding(), ), + + // Shell route with 3 tabs: Home, Products, Explore. StatefulShellRoute.indexedStack( - pageBuilder: (context, state, navigationShell) => CupertinoPage( - child: Scaffold( - body: navigationShell, - bottomNavigationBar: BottomNavigationBar( - currentIndex: navigationShell.currentIndex, - onTap: (index) { - navigationShell.goBranch(index); - }, - items: [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Home', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart), - label: 'Products', - ), - ]), + pageBuilder: (context, state, navigationShell) => CupertinoPage( + child: Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + currentIndex: navigationShell.currentIndex, + onTap: (index) => navigationShell.goBranch(index), + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Home', ), - ), - branches: [ - PlayxShellBranch( - name: Routes.home, - path: Paths.home, - builder: (context, state) => HomePage(), - binding: HomeBinding(), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart), + label: 'Products', + ), + BottomNavigationBarItem( + icon: Icon(Icons.explore), + label: 'Explore', + ), + ], ), - PlayxShellBranch( - path: Paths.products, - name: Routes.products, - builder: (context, state) => ProductsPage(), - binding: ProductsBinding(), - routes: [ - PlayxRoute( - path: Paths.details, - name: Routes.details, - builder: (context, state) => - ProductDetailsPage(product: state.extra as Product), - binding: DetailsBinding(), - ), - ]), - ]) + ), + ), + branches: [ + // Tab 1: Home (no children) + PlayxShellBranch( + name: Routes.home, + path: Paths.home, + builder: (context, state) => const HomePage(), + binding: HomeBinding(), + ), + + // Tab 2: Products → Product Details (parent-child push/pop) + PlayxShellBranch( + path: Paths.products, + name: Routes.products, + builder: (context, state) => const ProductsPage(), + binding: ProductsBinding(), + routes: [ + PlayxRoute( + path: Paths.productDetails, + name: Routes.productDetails, + builder: (context, state) => + ProductDetailsPage(product: state.extra as Product?), + binding: DetailsBinding(), + ), + ], + ), + + // Tab 3: Explore → Explore Details (parent-child push/pop) + PlayxShellBranch( + path: Paths.explore, + name: Routes.explore, + builder: (context, state) => const ExplorePage(), + binding: ExploreBinding(), + routes: [ + PlayxRoute( + path: Paths.exploreDetails, + name: Routes.exploreDetails, + builder: (context, state) => + ExploreDetailsPage(product: state.extra as Product?), + binding: ExploreDetailsBinding(), + ), + ], + ), + ], + ), ], ); } diff --git a/example/lib/navigation/routes.dart b/example/lib/navigation/routes.dart index d3bdf8a..886c294 100644 --- a/example/lib/navigation/routes.dart +++ b/example/lib/navigation/routes.dart @@ -1,13 +1,17 @@ abstract class Routes { + static const splash = 'splash'; static const home = 'home'; static const products = 'products'; - static const details = 'productDetails'; - static const splash = 'splash'; + static const productDetails = 'productDetails'; + static const explore = 'explore'; + static const exploreDetails = 'exploreDetails'; } abstract class Paths { + static const splash = '/splash'; static const home = '/home'; static const products = '/products'; - static const details = ':id'; - static const splash = '/splash'; + static const productDetails = ':id'; + static const explore = '/explore'; + static const exploreDetails = ':id'; } diff --git a/example/lib/products/details/details_binding.dart b/example/lib/products/details/details_binding.dart index b3a1c59..fbf438d 100644 --- a/example/lib/products/details/details_binding.dart +++ b/example/lib/products/details/details_binding.dart @@ -2,30 +2,31 @@ import 'package:flutter/widgets.dart'; import 'package:playx_navigation/playx_navigation.dart'; class DetailsBinding extends PlayxBinding { + @override + Future onInitApp() async { + print('PlayxNavigation: DetailsBinding onInitApp'); + } + @override Future onEnter(BuildContext context, GoRouterState state) async { - // Handle on Enter - print('PlayxNavigation: Product Details onEnter'); + final id = state.pathParameters['id']; + print('PlayxNavigation: Product Details #$id onEnter'); } @override Future onReEnter(BuildContext context, GoRouterState? state, bool wasPoppedAndReentered) async { print( - 'PlayxNavigation: Product Details onReEnter isStillInNavigationStack:$wasPoppedAndReentered'); + 'PlayxNavigation: Product Details onReEnter wasPoppedAndReentered:$wasPoppedAndReentered'); } @override Future onExit(BuildContext context) async { - // Handle On Exit print('PlayxNavigation: Product Details onExit'); } @override - Future onHidden( - BuildContext context, - ) async { - // Handle On Hidden + Future onHidden(BuildContext context) async { print('PlayxNavigation: Product Details onHidden'); } } diff --git a/example/lib/products/details/details_page.dart b/example/lib/products/details/details_page.dart index cb8c9ba..9ebca7f 100644 --- a/example/lib/products/details/details_page.dart +++ b/example/lib/products/details/details_page.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:playx_navigation_example/products/product.dart'; class ProductDetailsPage extends StatelessWidget { - final Product product; + final Product? product; const ProductDetailsPage({required this.product, super.key}); @override @@ -16,15 +16,15 @@ class ProductDetailsPage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - product.name, + product?.name ?? 'test', style: Theme.of(context).textTheme.headlineMedium, ), Text( - product.description, + product?.description ?? '', style: Theme.of(context).textTheme.bodyMedium, ), Text( - product.price.toString(), + product?.price.toString() ?? '-', style: Theme.of(context).textTheme.bodyLarge, ), ], diff --git a/example/lib/products/products_binding.dart b/example/lib/products/products_binding.dart index cc2f2ef..0adb2a9 100644 --- a/example/lib/products/products_binding.dart +++ b/example/lib/products/products_binding.dart @@ -2,30 +2,30 @@ import 'package:flutter/widgets.dart'; import 'package:playx_navigation/playx_navigation.dart'; class ProductsBinding extends PlayxBinding { + @override + Future onInitApp() async { + print('PlayxNavigation: ProductsBinding onInitApp'); + } + @override Future onEnter(BuildContext context, GoRouterState state) async { - // Handle on Enter - print('PlayxNavigation: ProductsBinding onEnter'); + print('PlayxNavigation: Products onEnter'); } @override Future onReEnter(BuildContext context, GoRouterState? state, bool wasPoppedAndReentered) async { print( - 'PlayxNavigation: ProductsBinding onReEnter wasPoppedAndReentered:$wasPoppedAndReentered'); + 'PlayxNavigation: Products onReEnter wasPoppedAndReentered:$wasPoppedAndReentered'); } @override Future onExit(BuildContext context) async { - // Handle On Exit - print('PlayxNavigation: ProductsBinding onExit'); + print('PlayxNavigation: Products onExit'); } @override - Future onHidden( - BuildContext context, - ) async { - // Handle On Hidden - print('PlayxNavigation: ProductsBinding onHidden '); + Future onHidden(BuildContext context) async { + print('PlayxNavigation: Products onHidden'); } } diff --git a/example/lib/products/products_page.dart b/example/lib/products/products_page.dart index 04cc469..19f3101 100644 --- a/example/lib/products/products_page.dart +++ b/example/lib/products/products_page.dart @@ -39,7 +39,7 @@ class _ProductsPageState extends State { final item = products[index]; return InkWell( onTap: () { - PlayxNavigation.toNamed(Routes.details, + PlayxNavigation.toNamed(Routes.productDetails, pathParameters: { 'id': item.id.toString(), }, diff --git a/example/lib/splash/splash_page.dart b/example/lib/splash/splash_page.dart index a20369f..7f1db3d 100644 --- a/example/lib/splash/splash_page.dart +++ b/example/lib/splash/splash_page.dart @@ -13,24 +13,44 @@ class _SplashPageState extends State { @override void initState() { super.initState(); - // print('PlayxNavigation: Splash onInit'); - Future.delayed(const Duration(seconds: 5), () { + _startApp(); + } + + Future _startApp() async { + // Wait for PlayxNavigation to finish boot + all onInitApp calls. + await PlayxNavigation.ensureInitialized; + print('PlayxNavigation: All bindings initialized: ' + '${PlayxNavigation.bindings.map((b) => b.runtimeType).toList()}'); + + // Simulate splash delay. + await Future.delayed(const Duration(seconds: 2)); + + if (mounted) { PlayxNavigation.offAllNamed(Routes.home); - }); + } } @override Widget build(BuildContext context) { - return const Scaffold( + return Scaffold( body: Center( - child: Text('Splash'), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Playx Navigation', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + 'Initializing bindings...', + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), ), ); } - - @override - void dispose() { - super.dispose(); - // print('PlayxNavigation: Splash onDispose'); - } } diff --git a/lib/src/binding/playx_page_state.dart b/lib/src/binding/playx_page_state.dart index ca4818d..566006f 100644 --- a/lib/src/binding/playx_page_state.dart +++ b/lib/src/binding/playx_page_state.dart @@ -1,10 +1,14 @@ /// Represents the various states a page can have in the PlayxNavigation system. /// -/// - [enter]: The page has been entered for the first time. -/// - [reEnter]: The page has been re-entered after being previously visited, without being removed from the stack. +/// - [pending]: The page widget has mounted but is not the active/visible route +/// (e.g., a parent route mounted as backstack when deep-linking to a child). +/// `onEnter` has NOT been called yet. +/// - [enter]: The page has been entered for the first time and `onEnter` has completed. +/// - [reEnter]: The page has been re-entered after being hidden. /// - [hidden]: The page is temporarily hidden but still in the navigation stack. /// - [exit]: The page has been exited and removed from the navigation stack. enum PlayxPageState { + pending, enter, reEnter, hidden, diff --git a/lib/src/routes/playx_page.dart b/lib/src/routes/playx_page.dart index b19e901..078fb18 100644 --- a/lib/src/routes/playx_page.dart +++ b/lib/src/routes/playx_page.dart @@ -5,15 +5,18 @@ import 'package:playx_navigation/src/binding/playx_page_state.dart'; /// An internal widget that wraps a route's content and manages the /// [PlayxBinding] lifecycle via standard widget lifecycle methods. /// -/// - [initState]: Fires [PlayxBinding.onEnter] and **blocks the child from -/// building** until it completes. This guarantees that any dependencies -/// registered in `onEnter` (e.g., GetX controllers) are available before -/// the page's `build` method runs. -/// - [dispose]: Fires [PlayxBinding.onExit] when the page is removed from the tree. +/// **Visibility-aware initialization:** +/// `onEnter` only fires when this page is the actual top/visible route. +/// If the page is mounted as a backstack item (e.g., a parent route when +/// deep-linking to a child), `onEnter` is deferred until the page becomes +/// the visible route. This prevents unnecessary controller registration +/// and API calls for pages the user hasn't navigated to yet. /// -/// This ensures `onEnter` fires exactly once per route entry, and `onExit` -/// fires exactly once when the route is truly removed (not just hidden in a -/// [StatefulShellRoute] branch). +/// **Lifecycle:** +/// - Mount as top route → `onEnter` fires immediately, blocking build. +/// - Mount as backstack → deferred, shows empty widget, fires `onEnter` +/// when the page becomes visible (e.g., user pops back to it). +/// - Dispose → `onExit` fires only if `onEnter` was previously called. class PlayxPage extends StatefulWidget { final PlayxBinding binding; final GoRouterState state; @@ -32,14 +35,47 @@ class PlayxPage extends StatefulWidget { class _PlayxPageState extends State { bool _initialized = false; + bool _listenerAdded = false; @override void initState() { super.initState(); - _initialize(); + _checkAndInitialize(); } - Future _initialize() async { + /// Checks whether this page is the current top route. + /// + /// If it is, `onEnter` is called immediately (blocking the build). + /// If not, the page is in the backstack — we mark it as [PlayxPageState.pending] + /// and listen for route changes to fire `onEnter` when it becomes visible. + Future _checkAndInitialize() async { + if (_isCurrentlyTopRoute()) { + await _performOnEnter(); + } else { + // Deferred — this page is mounted as a backstack item. + widget.binding.currentState = PlayxPageState.pending; + _listenerAdded = true; + PlayxNavigation.addRouteChangeListener(_onRouteChanged); + } + } + + /// Listens for route changes to detect when this deferred page becomes visible. + void _onRouteChanged() { + if (_initialized) return; + if (_isCurrentlyTopRoute()) { + _removeListener(); + _performOnEnter(); + } + } + + /// Determines if this page's matched location is the current top location. + bool _isCurrentlyTopRoute() { + final currentLocation = PlayxNavigation.currentState?.matchedLocation; + return currentLocation == widget.state.matchedLocation; + } + + /// Fires `onEnter` on the binding and marks the page as initialized. + Future _performOnEnter() async { widget.binding.currentState = PlayxPageState.enter; await widget.binding.onEnter(context, widget.state); if (mounted) { @@ -49,10 +85,21 @@ class _PlayxPageState extends State { } } + void _removeListener() { + if (_listenerAdded) { + PlayxNavigation.removeRouteChangeListener(_onRouteChanged); + _listenerAdded = false; + } + } + @override void dispose() { - widget.binding.onExit(context); - widget.binding.currentState = PlayxPageState.exit; + _removeListener(); + // Only fire onExit if onEnter was actually called. + if (_initialized) { + widget.binding.onExit(context); + widget.binding.currentState = PlayxPageState.exit; + } super.dispose(); } From 15b801f77c127e51164b1aaaa117e84c659ba9eb Mon Sep 17 00:00:00 2001 From: basemosama Date: Thu, 2 Apr 2026 06:10:57 +0200 Subject: [PATCH 4/4] feat: Add custom loading widget and initialization blocking for routes - Added `loadingWidget` option to `PlayxRoute` and `PlayxShellBranch` to be displayed while `onEnter` is initializing. - Implemented initialization blocking to ensure the route's child widget is only built after `onEnter` completes. - Updated `PlayxPage` and documentation to support the new loading state and lifecycle behavior. - --- CHANGELOG.md | 2 ++ README.md | 2 ++ example/lib/navigation/pages.dart | 3 +++ example/lib/products/details/details_binding.dart | 2 ++ lib/src/routes/playx_page.dart | 7 ++++++- lib/src/routes/playx_route.dart | 7 +++++++ lib/src/routes/playx_shell_branch.dart | 2 ++ 7 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83689bb..f3b50ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - **New `bindings`**: A static getter that returns an unmodifiable list of all discovered `PlayxBinding` instances from the route tree. - **New `findBinding()`**: Type-safe lookup to retrieve a specific binding by its concrete type. Throws `StateError` if not found. - **New `findBindingOrNull()`**: Same as `findBinding()` but returns `null` instead of throwing if no match is found. +- **Custom Loading Widget**: Added `loadingWidget` option to `PlayxRoute` and `PlayxShellBranch`. This widget is displayed while `onEnter` is being initialized, defaulting to `SizedBox.shrink()`. +- **Initialization Blocking**: The route's child widget is only built after `onEnter` completes. This ensures dependencies registered in `onEnter` (e.g., GetX controllers) are available during the first build. ### Lifecycle Refactoring (Breaking Changes) - **Removed redirect hack**: Binding lifecycle methods (`onEnter`, `onReEnter`) are no longer triggered through GoRoute's `redirect` callback. The `redirect` parameter on `PlayxRoute` is now passed through directly for user-defined redirection logic only. diff --git a/README.md b/README.md index 8bff7f4..6b8b47f 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ After initialization, all discovered `PlayxBinding` instances are stored and can - **App Initialization:** Register app-level dependencies (repositories, datasources, services) via `onInitApp`, called once at startup. - **Widget-Lifecycle Driven:** `onEnter` fires from `PlayxPage.initState` (once on mount), `onExit` fires from `PlayxPage.dispose` (only when truly removed from the tree). +- **Initialization Blocking:** The route's child widget is only built after its binding's `onEnter` completes. This guarantees any dependencies registered in `onEnter` (like GetX controllers) are available during the first build. +- **Custom Loading Widget:** Each route can provide a `loadingWidget` to be displayed while `onEnter` is initializing (defaults to `SizedBox.shrink()`). - **Route-Change Driven:** `onHidden` and `onReEnter` fire from the route-change listener when the top route changes. - **Shell Route Aware:** In `StatefulShellRoute`, branch switching fires `onHidden`/`onReEnter` (not `onExit`/`onEnter`) because the widget stays alive in its branch. - **Binding Access:** Retrieve any binding by type via `PlayxNavigation.findBinding()` after initialization. diff --git a/example/lib/navigation/pages.dart b/example/lib/navigation/pages.dart index 7f6510c..e55e84b 100644 --- a/example/lib/navigation/pages.dart +++ b/example/lib/navigation/pages.dart @@ -85,6 +85,9 @@ class AppPages { builder: (context, state) => ProductDetailsPage(product: state.extra as Product?), binding: DetailsBinding(), + loadingWidget: const Center( + child: CircularProgressIndicator(), + ), ), ], ), diff --git a/example/lib/products/details/details_binding.dart b/example/lib/products/details/details_binding.dart index fbf438d..e997c20 100644 --- a/example/lib/products/details/details_binding.dart +++ b/example/lib/products/details/details_binding.dart @@ -11,6 +11,8 @@ class DetailsBinding extends PlayxBinding { Future onEnter(BuildContext context, GoRouterState state) async { final id = state.pathParameters['id']; print('PlayxNavigation: Product Details #$id onEnter'); + // Simulate data fetching to show loading widget + await Future.delayed(const Duration(seconds: 2)); } @override diff --git a/lib/src/routes/playx_page.dart b/lib/src/routes/playx_page.dart index 078fb18..56f5f12 100644 --- a/lib/src/routes/playx_page.dart +++ b/lib/src/routes/playx_page.dart @@ -22,11 +22,16 @@ class PlayxPage extends StatefulWidget { final GoRouterState state; final Widget child; + /// An optional widget that is displayed while the [binding]'s `onEnter` is being initialized. + /// Defaults to [SizedBox.shrink()]. + final Widget? loadingWidget; + const PlayxPage({ super.key, required this.binding, required this.state, required this.child, + this.loadingWidget, }); @override @@ -106,7 +111,7 @@ class _PlayxPageState extends State { @override Widget build(BuildContext context) { if (!_initialized) { - return const SizedBox.shrink(); + return widget.loadingWidget ?? const SizedBox.shrink(); } return widget.child; } diff --git a/lib/src/routes/playx_route.dart b/lib/src/routes/playx_route.dart index d875e56..6834704 100644 --- a/lib/src/routes/playx_route.dart +++ b/lib/src/routes/playx_route.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:playx_navigation/src/routes/playx_page.dart'; @@ -80,6 +81,10 @@ class PlayxRoute extends GoRoute { /// Defaults to [PlayxPageConfiguration()]. final PlayxPageConfiguration pageConfiguration; + /// An optional widget that is displayed while the [binding]'s `onEnter` is being initialized. + /// Defaults to [SizedBox.shrink()]. + final Widget? loadingWidget; + PlayxRoute({ required super.path, super.name, @@ -88,6 +93,7 @@ class PlayxRoute extends GoRoute { this.pageConfiguration = const PlayxPageConfiguration(), super.parentNavigatorKey, this.binding, + this.loadingWidget, super.redirect, super.onExit, super.routes = const [], @@ -100,6 +106,7 @@ class PlayxRoute extends GoRoute { : PlayxPage( binding: binding, state: state, + loadingWidget: loadingWidget, child: builder(ctx, state), ), state: state, diff --git a/lib/src/routes/playx_shell_branch.dart b/lib/src/routes/playx_shell_branch.dart index 0c11c8c..7345d7e 100644 --- a/lib/src/routes/playx_shell_branch.dart +++ b/lib/src/routes/playx_shell_branch.dart @@ -27,6 +27,7 @@ class PlayxShellBranch extends StatefulShellBranch { PlayxPageConfiguration pageConfiguration = const PlayxPageConfiguration(), GlobalKey? parentNavigatorKey, PlayxBinding? binding, + Widget? loadingWidget, GoRouterRedirect? redirect, ExitCallback? onExit, List routes = const [], @@ -39,6 +40,7 @@ class PlayxShellBranch extends StatefulShellBranch { pageConfiguration: pageConfiguration, parentNavigatorKey: parentNavigatorKey, binding: binding, + loadingWidget: loadingWidget, redirect: redirect, onExit: onExit, routes: routes,