From 4d9177afa32c1d7fe8d9cc710f65eb729fb9ffd5 Mon Sep 17 00:00:00 2001 From: Cagatay Ulusoy Date: Sat, 24 Aug 2024 12:47:46 +0300 Subject: [PATCH] Select bottom nav bar on browser address bar --- coffee_maker/lib/main.dart | 1 - .../router/entities/app_navigation_stack.dart | 59 --- .../entities/app_route_configuration.dart | 45 ++- .../app/router/entities/app_route_page.dart | 180 ++++++---- .../app/router/entities/app_route_path.dart | 45 --- .../entities/app_route_uri_template.dart | 121 +++++++ .../view/app_route_information_parser.dart | 55 ++- .../app/router/view/app_route_observer.dart | 4 + .../app/router/view/app_router_delegate.dart | 63 +++- .../router/view_model/router_view_model.dart | 340 ++++++++++++------ .../add_water/ui/view/add_water_screen.dart | 7 +- .../widgets/add_water_screen_content.dart | 5 +- .../view/widgets/add_water_screen_footer.dart | 7 +- .../add_water_step_order_not_found.dart | 10 +- .../ui/view/onboarding_modal_sheet_page.dart | 5 +- .../domain/entities/coffee_maker_step.dart | 12 + .../orders/ui/view/orders_screen.dart | 20 +- .../ui/view/widgets/order_screen_content.dart | 30 +- .../view_model/orders_screen_view_model.dart | 16 - .../lib/src/theme_data/app_theme_data.dart | 1 - 20 files changed, 657 insertions(+), 369 deletions(-) delete mode 100644 coffee_maker_navigator_2/lib/app/router/entities/app_navigation_stack.dart delete mode 100644 coffee_maker_navigator_2/lib/app/router/entities/app_route_path.dart create mode 100644 coffee_maker_navigator_2/lib/app/router/entities/app_route_uri_template.dart diff --git a/coffee_maker/lib/main.dart b/coffee_maker/lib/main.dart index b94552b4..cfb435bd 100644 --- a/coffee_maker/lib/main.dart +++ b/coffee_maker/lib/main.dart @@ -2,7 +2,6 @@ import 'package:coffee_maker/entities/grouped_coffee_orders.dart'; import 'package:coffee_maker/entities/mock_coffee_orders.dart'; import 'package:coffee_maker/home/home_screen.dart'; import 'package:demo_ui_components/demo_ui_components.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; diff --git a/coffee_maker_navigator_2/lib/app/router/entities/app_navigation_stack.dart b/coffee_maker_navigator_2/lib/app/router/entities/app_navigation_stack.dart deleted file mode 100644 index 8aa04e18..00000000 --- a/coffee_maker_navigator_2/lib/app/router/entities/app_navigation_stack.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:coffee_maker_navigator_2/app/router/entities/app_route_page.dart'; -import 'package:coffee_maker_navigator_2/features/orders/domain/entities/coffee_maker_step.dart'; -import 'package:equatable/equatable.dart'; - -class AppNavigationStack extends Equatable { - final List pages; - - const AppNavigationStack({required this.pages}); - - @override - List get props => [pages]; - - AppRoutePage get lastPage => pages.last; - - factory AppNavigationStack.bootstrapStack() { - return const AppNavigationStack( - pages: [BootstrapRoutePage()], - ); - } - - factory AppNavigationStack.ordersStack({ - CoffeeMakerStep? step, - String? coffeeOrderId, - bool shouldShowOnboardingModal = false, - }) { - return AppNavigationStack( - pages: [ - const OrdersRoutePage(), - if (shouldShowOnboardingModal) const OnboardingModalRoutePage(), - if (step == CoffeeMakerStep.grind && coffeeOrderId != null) - GrindCoffeeModalRoutePage( - coffeeOrderId: coffeeOrderId, - ), - if (step == CoffeeMakerStep.addWater && coffeeOrderId != null) - AddWaterRoutePage(coffeeOrderId), - if (step == CoffeeMakerStep.ready && coffeeOrderId != null) - ReadyCoffeeModalRoutePage( - coffeeOrderId: coffeeOrderId, - ), - ], - ); - } - - factory AppNavigationStack.loginStack() { - return const AppNavigationStack( - pages: [LoginRoutePage()], - ); - } - - factory AppNavigationStack.tutorialsStack([CoffeeMakerStep? step]) { - return AppNavigationStack( - pages: [ - const OrdersRoutePage(), - const TutorialsRoutePage(), - if (step != null) SingleTutorialRoutePage(step), - ], - ); - } -} diff --git a/coffee_maker_navigator_2/lib/app/router/entities/app_route_configuration.dart b/coffee_maker_navigator_2/lib/app/router/entities/app_route_configuration.dart index 40ff705f..bd0dc6c8 100644 --- a/coffee_maker_navigator_2/lib/app/router/entities/app_route_configuration.dart +++ b/coffee_maker_navigator_2/lib/app/router/entities/app_route_configuration.dart @@ -1,15 +1,46 @@ -import 'package:coffee_maker_navigator_2/app/router/entities/app_route_path.dart'; +import 'package:coffee_maker_navigator_2/app/router/entities/app_route_uri_template.dart'; +/// This class provides a structured way to manage and represent the current navigation state, +/// including both the route and any relevant parameters. +/// +/// [AppRouteConfiguration] represents the configuration of a specific route in the application, +/// holding both the static structure of the route as defined by the [AppRouteUriTemplate] and +/// the dynamic aspects such as query parameters. +/// +/// In the context of Flutter's Navigator 2.0, this configuration is essential for translating +/// between the application's navigation state and the URL displayed in the browser. It enables +/// deep linking, dynamic navigation, and synchronization between the app's state and the browser URL. +/// +/// **Components:** +/// - [appRouteUriTemplate]: A value from the [AppRouteUriTemplate] enum, representing the static +/// template of the route. It defines the modal or screen that should be navigated to based +/// on the URI path. +/// - [queryParams]: A map of query parameters, allowing additional dynamic information to be passed +/// with the route (e.g., selected tab, order id, etc.). This supports more complex navigation +/// patterns and enables passing state information directly through the URL. +/// +/// **Usage:** +/// - **URL Generation:** The `toUri()` method converts the route configuration into a full URI, +/// combining the path from [AppRouteUriTemplate] with the actual query parameter values. This URI +/// is used to update the browser's address bar, ensuring that the visible URL reflects the current +/// state of the application. +/// - **Consistency with Defined Routes:** By utilizing [AppRouteUriTemplate], this class ensures that +/// all route configurations are consistent with the predefined route templates, making navigation +/// predictable and easier to manage. class AppRouteConfiguration { - final AppRoutePath appRoutePath; - final Map queryParams; - - static const queryParamId = 'id'; + final AppRouteUriTemplate appRouteUriTemplate; + final QueryParams queryParams; const AppRouteConfiguration({ - required this.appRoutePath, + required this.appRouteUriTemplate, this.queryParams = const {}, }); - Uri toUri() => Uri(path: appRoutePath.path, queryParameters: queryParams); + /// Converts the route configuration to a URI, combining the path name from the template + /// and any provided query parameters. This is used for generating URLs that reflect + /// the application's current state, aiding in deep linking and navigation state management. + Uri toUri() => + Uri(path: appRouteUriTemplate.path, queryParameters: queryParams); } + +typedef QueryParams = Map; diff --git a/coffee_maker_navigator_2/lib/app/router/entities/app_route_page.dart b/coffee_maker_navigator_2/lib/app/router/entities/app_route_page.dart index 9d49c2db..100f76e7 100644 --- a/coffee_maker_navigator_2/lib/app/router/entities/app_route_page.dart +++ b/coffee_maker_navigator_2/lib/app/router/entities/app_route_page.dart @@ -1,5 +1,5 @@ import 'package:coffee_maker_navigator_2/app/router/entities/app_route_configuration.dart'; -import 'package:coffee_maker_navigator_2/app/router/entities/app_route_path.dart'; +import 'package:coffee_maker_navigator_2/app/router/entities/app_route_uri_template.dart'; import 'package:coffee_maker_navigator_2/features/add_water/ui/view/add_water_screen.dart'; import 'package:coffee_maker_navigator_2/features/login/ui/view/login_screen.dart'; import 'package:coffee_maker_navigator_2/features/onboarding/ui/view/onboarding_modal_sheet_page.dart'; @@ -14,28 +14,53 @@ import 'package:coffee_maker_navigator_2/features/orders/ui/view/orders_screen.d import 'package:coffee_maker_navigator_2/features/tutorial/view/single_tutorial_screen.dart'; import 'package:coffee_maker_navigator_2/features/tutorial/view/tutorials_screen.dart'; import 'package:coffee_maker_navigator_2/utils/extensions/context_extensions.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:wolt_di/wolt_di.dart'; import 'package:wolt_modal_sheet/wolt_modal_sheet.dart'; +/// `AppRoutePage` is a sealed class that extends Flutter's [Page] class. It serves as a base +/// class for all route pages within the application, defining a consistent interface and behavior +/// for routes. +/// +/// One of the main benefits of a sealed class is that it guarantees exhaustiveness. +/// Since all possible subclasses of `AppRoutePage` are known at compile time, the application can +/// ensure that every route is explicitly handled. This helps prevent errors that can arise from +/// unhandled routes or navigation scenarios, leading to more robust and predictable routing behavior. +/// When working with pattern matching or switch cases on instances of `AppRoutePage`, the compiler +/// can enforce that all cases are covered, reducing the risk of runtime errors. +/// +/// Each subclass of `AppRoutePage` must specify an [AppRouteUriTemplate] that represents the +/// static route template associated with the page. The `queryParams` getter allows each page +/// to define any dynamic query parameters it might need. This is crucial for passing state or +/// configuration data through the URL, supporting more sophisticated navigation scenarios and +/// state management.x sealed class AppRoutePage extends Page { - const AppRoutePage({LocalKey? key, this.configuration}) : super(key: key); - - final AppRouteConfiguration? configuration; + const AppRoutePage({LocalKey? key}) : super(key: key); + + /// Provides dynamic query parameters for the route. + /// By default, this method returns `null`, indicating no query parameters. + /// Subclasses can override this to provide specific query parameters needed + /// for the route, facilitating dynamic navigation scenarios. + QueryParams? get queryParams => null; + + /// An abstract getter that subclasses must implement to return the + /// corresponding [AppRouteUriTemplate] for the page. This links each + /// page to a specific URI structure, ensuring that the navigation system + /// can accurately map the application's state to the correct URI. + AppRouteUriTemplate get appRouteUriTemplate; } class BootstrapRoutePage extends AppRoutePage { + static const String routeName = 'bootstrap'; + @override String get name => routeName; - static const String routeName = 'bootstrap'; + @override + AppRouteUriTemplate get appRouteUriTemplate => AppRouteUriTemplate.bootstrap; - const BootstrapRoutePage() - : super( - key: const ValueKey('BootstrapRoutePage'), - configuration: - const AppRouteConfiguration(appRoutePath: AppRoutePath.bootstrap), - ); + const BootstrapRoutePage() : super(key: const ValueKey('BootstrapRoutePage')); @override Route createRoute(BuildContext context) { @@ -54,14 +79,12 @@ class LoginRoutePage extends AppRoutePage { @override String get name => routeName; + @override + AppRouteUriTemplate get appRouteUriTemplate => AppRouteUriTemplate.login; + static const String routeName = 'login'; - const LoginRoutePage() - : super( - key: const ValueKey('LoginRoutePage'), - configuration: - const AppRouteConfiguration(appRoutePath: AppRoutePath.login), - ); + const LoginRoutePage() : super(key: const ValueKey('LoginRoutePage')); @override Route createRoute(BuildContext context) { @@ -73,74 +96,80 @@ class LoginRoutePage extends AppRoutePage { } class OrdersRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate => AppRouteUriTemplate.orders; + + @override + QueryParams? get queryParams => { + AppRouteUriTemplate.queryParamKeyOrderScreenTab: + _visibleOrderScreenNavBarTab.value.queryParamName + }; + @override String get name => routeName; static const String routeName = 'orders'; - const OrdersRoutePage([this.initialBottomNavBarTab]) - : super( - key: const ValueKey('OrdersRoutePage'), - configuration: - const AppRouteConfiguration(appRoutePath: AppRoutePath.orders), - ); + const OrdersRoutePage(this._visibleOrderScreenNavBarTab) + : super(key: const ValueKey('OrdersRoutePage')); - final CoffeeMakerStep? initialBottomNavBarTab; + final ValueListenable _visibleOrderScreenNavBarTab; @override Route createRoute(BuildContext context) { return MaterialPageRoute( - builder: (context) => OrdersScreen( - initialBottomNavBarTab, - ), + builder: (context) => const OrdersScreen(), settings: this, ); } } class AddWaterRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate => AppRouteUriTemplate.addWater; + + @override + QueryParams? get queryParams => + {AppRouteUriTemplate.queryParamKeyId: coffeeOrderId}; + @override String get name => routeName; static const String routeName = 'addWater'; - AddWaterRoutePage(this.coffeeOrderId) - : super( - key: ValueKey(coffeeOrderId), - configuration: AppRouteConfiguration( - appRoutePath: AppRoutePath.addWater, - queryParams: {AppRoutePath.queryParamId: coffeeOrderId}, - ), - ); + AddWaterRoutePage(this.coffeeOrderId) : super(key: ValueKey(coffeeOrderId)); final String coffeeOrderId; @override Route createRoute(BuildContext context) { return MaterialPageRoute( - builder: (context) { - return AddWaterScreen( - coffeeOrderId: coffeeOrderId, - ); - }, + builder: (_) => AddWaterScreen(coffeeOrderId: coffeeOrderId), settings: this, ); } } class SingleTutorialRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate { + switch (coffeeMakerStep) { + case CoffeeMakerStep.grind: + return AppRouteUriTemplate.grindTutorial; + case CoffeeMakerStep.addWater: + return AppRouteUriTemplate.waterTutorial; + case CoffeeMakerStep.ready: + return AppRouteUriTemplate.readyTutorial; + } + } + @override String get name => routeName; static const String routeName = 'singleTutorial'; SingleTutorialRoutePage(this.coffeeMakerStep) - : super( - key: ValueKey(coffeeMakerStep), - configuration: AppRouteConfiguration( - appRoutePath: AppRoutePath.fromCoffeeMakerStep(coffeeMakerStep), - ), - ); + : super(key: ValueKey(coffeeMakerStep)); final CoffeeMakerStep coffeeMakerStep; @@ -155,17 +184,15 @@ class SingleTutorialRoutePage extends AppRoutePage { } class TutorialsRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate => AppRouteUriTemplate.tutorials; + @override String get name => routeName; static const String routeName = 'tutorials'; - const TutorialsRoutePage() - : super( - key: const ValueKey('TutorialsRoutePage'), - configuration: - const AppRouteConfiguration(appRoutePath: AppRoutePath.tutorials), - ); + const TutorialsRoutePage() : super(key: const ValueKey('TutorialsRoutePage')); @override Route createRoute(BuildContext context) { @@ -177,17 +204,16 @@ class TutorialsRoutePage extends AppRoutePage { } class OnboardingModalRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate => AppRouteUriTemplate.onboarding; + @override String get name => routeName; static const String routeName = 'onboarding'; const OnboardingModalRoutePage() - : super( - key: const ValueKey('OnboardingRoutePage'), - configuration: const AppRouteConfiguration( - appRoutePath: AppRoutePath.onboarding), - ); + : super(key: const ValueKey('OnboardingRoutePage')); @override Route createRoute(BuildContext context) { @@ -196,15 +222,19 @@ class OnboardingModalRoutePage extends AppRoutePage { pageListBuilderNotifier: ValueNotifier( (context) => [OnboardingModalSheetPage()], ), - onModalDismissedWithDrag: - context.routerViewModel.onCloseOnboardingModalSheet, - onModalDismissedWithBarrierTap: - context.routerViewModel.onCloseOnboardingModalSheet, ); } } class GrindCoffeeModalRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate => + AppRouteUriTemplate.grindCoffeeModal; + + @override + QueryParams? get queryParams => + {AppRouteUriTemplate.queryParamKeyId: coffeeOrderId}; + @override String get name => routeName; @@ -215,10 +245,6 @@ class GrindCoffeeModalRoutePage extends AppRoutePage { GrindCoffeeModalRoutePage({required this.coffeeOrderId}) : super( key: ValueKey('GrindCoffeeModalRoutePage-$coffeeOrderId'), - configuration: AppRouteConfiguration( - appRoutePath: AppRoutePath.grindCoffeeModal, - queryParams: {AppRoutePath.queryParamId: coffeeOrderId}, - ), ); @override @@ -241,13 +267,13 @@ class GrindCoffeeModalRoutePage extends AppRoutePage { onCoffeeOrderGrindCompleted: () { viewModel.onOrderStatusChange( coffeeOrderId, CoffeeMakerStep.addWater); - context.routerViewModel.onGrindCoffeeCompleted(); + context.routerViewModel.onOrderStepCompleted(); }), RejectOrderModalPage( coffeeOrderId: coffeeOrderId, onCoffeeOrderRejected: () { viewModel.onOrderStatusChange(coffeeOrderId); - context.routerViewModel.onGrindCoffeeCompleted(); + context.routerViewModel.onOrderStepCompleted(); }, ), ] else @@ -260,6 +286,14 @@ class GrindCoffeeModalRoutePage extends AppRoutePage { } class ReadyCoffeeModalRoutePage extends AppRoutePage { + @override + AppRouteUriTemplate get appRouteUriTemplate => + AppRouteUriTemplate.readyCoffeeModal; + + @override + QueryParams? get queryParams => + {AppRouteUriTemplate.queryParamKeyId: coffeeOrderId}; + @override String get name => routeName; @@ -268,13 +302,7 @@ class ReadyCoffeeModalRoutePage extends AppRoutePage { final String coffeeOrderId; ReadyCoffeeModalRoutePage({required this.coffeeOrderId}) - : super( - key: ValueKey('ReadyCoffeeModalRoutePage-$coffeeOrderId'), - configuration: AppRouteConfiguration( - appRoutePath: AppRoutePath.readyCoffeeModal, - queryParams: {AppRoutePath.queryParamId: coffeeOrderId}, - ), - ); + : super(key: ValueKey('ReadyCoffeeModalRoutePage-$coffeeOrderId')); @override Route createRoute(BuildContext context) { @@ -294,14 +322,14 @@ class ReadyCoffeeModalRoutePage extends AppRoutePage { coffeeOrderId: coffeeOrderId, onCoffeeOrderServed: () { viewModel.onOrderStatusChange(coffeeOrderId); - context.routerViewModel.onReadyCoffeeStepCompleted(); + context.routerViewModel.onOrderStepCompleted(); }, ), OfferRecommendationModalPage.build( coffeeOrderId: coffeeOrderId, onCoffeeOrderServed: () { viewModel.onOrderStatusChange(coffeeOrderId); - context.routerViewModel.onReadyCoffeeStepCompleted(); + context.routerViewModel.onOrderStepCompleted(); }, ), ] else diff --git a/coffee_maker_navigator_2/lib/app/router/entities/app_route_path.dart b/coffee_maker_navigator_2/lib/app/router/entities/app_route_path.dart deleted file mode 100644 index 9b71b45f..00000000 --- a/coffee_maker_navigator_2/lib/app/router/entities/app_route_path.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:coffee_maker_navigator_2/features/orders/domain/entities/coffee_maker_step.dart'; - -enum AppRoutePath { - bootstrap('/'), - login('/login'), - orders('/orders'), - tutorials('/tutorials'), - grindTutorial('/tutorials/grind'), - waterTutorial('/tutorials/water'), - readyTutorial('/tutorials/ready'), - addWater('/orders/addWater', [queryParamId]), - grindCoffeeModal('/orders/grind', [queryParamId, queryParamPageIndex]), - readyCoffeeModal('/orders/ready', [queryParamId, queryParamPageIndex]), - unknown('/unknown'), - onboarding('/welcome'); - - final String path; - final List params; - - static const queryParamId = 'id'; - static const queryParamPageIndex = 'pageIndex'; - - const AppRoutePath(this.path, [this.params = const []]); - - static AppRoutePath findFromPageName(String path, - [Iterable params = const []]) { - return AppRoutePath.values.firstWhere( - (element) => - element.path == path && - params.every((e) => element.params.contains(e)), - orElse: () => AppRoutePath.unknown, - ); - } - - static AppRoutePath fromCoffeeMakerStep(CoffeeMakerStep step) { - switch (step) { - case CoffeeMakerStep.grind: - return grindTutorial; - case CoffeeMakerStep.addWater: - return waterTutorial; - case CoffeeMakerStep.ready: - return readyTutorial; - } - } -} diff --git a/coffee_maker_navigator_2/lib/app/router/entities/app_route_uri_template.dart b/coffee_maker_navigator_2/lib/app/router/entities/app_route_uri_template.dart new file mode 100644 index 00000000..c3033c82 --- /dev/null +++ b/coffee_maker_navigator_2/lib/app/router/entities/app_route_uri_template.dart @@ -0,0 +1,121 @@ +import 'package:coffee_maker_navigator_2/app/router/view/app_route_information_parser.dart'; +import 'package:coffee_maker_navigator_2/features/orders/domain/entities/coffee_maker_step.dart'; + +/// Represents the different route URI templates available in the application, each associated +/// with a specific screen or modal. This enum defines the static structure of URI paths and +/// expected query parameters, mapping these defined string paths to the application's navigation +/// routes. +/// +/// This enum provides a centralized, consistent way to define and reference the URI paths used +/// throughout the app, ensuring that route handling is unified and easier to manage. By clearly +/// outlining the available routes and their associated query parameters, it helps avoid +/// hard-coded strings scattered throughout the codebase, reducing the likelihood of errors. +/// +/// **Why this Enum is Necessary:** +/// - **Consistency:** Centralizes the definition of route paths, ensuring all references to routes +/// use a standardized format. +/// - **Clarity:** Makes it clear what routes are available and what parameters they expect, improving +/// code readability and maintainability. +/// - **Integration:** Provides a straightforward way to map URIs to specific routes, aiding in navigation +/// handling and making deep linking easier to manage. +/// - **Exhaustive Route Identification:** Helps to find the corresponding route from the URI in an +/// exhaustive manner, ensuring that every possible route defined in the application has a clear and +/// explicit mapping. This reduces ambiguity in navigation and ensures that all paths are accounted for. +/// +/// **Where it is Used:** +/// - **Navigation Logic:** Used in conjunction with classes like `AppRouteConfiguration` to manage the +/// dynamic aspects of routing, such as the actual values of query parameters. +/// - **Route Matching:** The [AppRouteInformationParser] class used the static `findFromUri` +/// method defined in this enum to determine the correct route based on a given URI, allowing +/// the app to interpret and respond to changes in the browser's address bar. +/// This exhaustive approach ensures that all defined routes are considered and matched appropriately. +/// +/// Each enum value corresponds to a unique route in the app, defining the path and, optionally, any query +/// parameters that can be used to pass additional data or control the state of a screen. +/// +/// Additional Fields: +/// - `path`: The string path associated with each route, used for URI matching. +/// - `params`: Optional list of query parameter keys expected for the route, used for passing additional data. +enum AppRouteUriTemplate { + bootstrap('/'), + login('/login'), + orders('/orders', [queryParamKeyOrderScreenTab]), + tutorials('/tutorials'), + grindTutorial('/tutorials/grind'), + waterTutorial('/tutorials/water'), + readyTutorial('/tutorials/ready'), + addWater('/orders/addWater', [queryParamKeyId]), + grindCoffeeModal('/orders/grind', [queryParamKeyId]), + readyCoffeeModal('/orders/ready', [queryParamKeyId]), + unknown('/unknown'), + onboarding('/welcome'); + + final String path; // The URL path for the route. + final List + params; // Optional list of query parameter keys associated with the route. + + static const queryParamKeyId = + 'id'; // Standard query parameter for item identification. + static const queryParamKeyOrderScreenTab = + 'tab'; // Query parameter for specifying order screen tabs. + + const AppRouteUriTemplate(this.path, [this.params = const []]); + + /// Finds and returns the [AppRouteUriTemplate] enum value based on the provided URI. + /// This method uses both the path and the query parameters of the URI to match and + /// identify the corresponding route. If no exact match is found, it defaults to + /// [AppRouteUriTemplate.unknown]. + /// + /// Parameters: + /// - uri: The [Uri] object representing the requested route. + /// + /// Returns: + /// - [AppRouteUriTemplate]: The corresponding enum value based on the URI path and query parameters. + static AppRouteUriTemplate findFromUri(Uri uri) { + final path = uri.path; + final queryParams = uri.queryParameters; + + return AppRouteUriTemplate.values.firstWhere( + (element) { + // Check if the path matches + if (element.path != path) { + return false; + } + + // Check if all expected query parameters are present + if (element.params.isNotEmpty) { + for (String param in element.params) { + if (!queryParams.containsKey(param)) { + return false; // Expected query param not found + } + } + } + + // If the path matches and all expected query parameters are present, return true + return true; + }, + orElse: () => AppRouteUriTemplate + .unknown, // Default to unknown if no match is found + ); + } + + /// Maps a [CoffeeMakerStep] to the corresponding tutorial route. + /// This method provides a straightforward way to navigate to specific tutorial screens + /// based on the given coffee maker step. + /// + /// Parameters: + /// - step: The [CoffeeMakerStep] enum value representing the tutorial for the state. + /// + /// Returns: + /// - [AppRouteUriTemplate]: The corresponding tutorial route for the provided step. + static AppRouteUriTemplate fromCoffeeMakerStep(CoffeeMakerStep step) { + switch (step) { + case CoffeeMakerStep.grind: + return grindTutorial; + case CoffeeMakerStep.addWater: + return waterTutorial; + case CoffeeMakerStep.ready: + return readyTutorial; + } + } +} diff --git a/coffee_maker_navigator_2/lib/app/router/view/app_route_information_parser.dart b/coffee_maker_navigator_2/lib/app/router/view/app_route_information_parser.dart index 43f95c6b..0b13a5c8 100644 --- a/coffee_maker_navigator_2/lib/app/router/view/app_route_information_parser.dart +++ b/coffee_maker_navigator_2/lib/app/router/view/app_route_information_parser.dart @@ -1,30 +1,67 @@ import 'package:coffee_maker_navigator_2/app/router/entities/app_route_configuration.dart'; -import 'package:coffee_maker_navigator_2/app/router/entities/app_route_path.dart'; +import 'package:coffee_maker_navigator_2/app/router/entities/app_route_uri_template.dart'; import 'package:flutter/material.dart'; -typedef QueryParams = Map; - +/// Parses and restores [RouteInformation] for the application, facilitating the interaction +/// between the app's internal navigation state and the external representation in the browser's URL. +/// +/// This class extends [RouteInformationParser] and handles the conversion between +/// [RouteInformation] (used for browser URL) and [AppRouteConfiguration] (used for app +/// navigation state). +/// +/// In the context of Flutter Navigator 2.0, the [AppRouteInformationParser] plays a pivotal role +/// in managing navigation state transitions, ensuring that the app responds correctly to both +/// user-initiated navigation (e.g., via the browser's address bar) and programmatic navigation +/// changes within the app. It ensures that navigation is consistent, predictable, and synced with the +/// visible URL. +/// +/// **Key Methods:** +/// - `parseRouteInformation`: This method is responsible for translating a [RouteInformation] object, +/// typically generated from a URL change (e.g., when a user types a new address or uses the browser +/// back button), into an [AppRouteConfiguration] object. By doing this, it allows the application +/// to update its navigation stack to reflect changes in the browser's address bar, ensuring that +/// the app's state is in sync with the URL. +/// - `restoreRouteInformation`: This method converts an [AppRouteConfiguration] back into a +/// [RouteInformation] object. It is used to update the browser's URL to reflect the current +/// navigation state within the app, ensuring that the displayed URL is always up-to-date with the +/// app's state. This is crucial for maintaining the correct state during internal navigation and +/// for enabling users to bookmark or share URLs accurately. +/// +/// By using [AppRouteUriTemplate], this class ensures that the parsing and restoration processes +/// are exhaustive and aligned with the predefined route templates. This comprehensive approach helps +/// avoid mismatches and ensures that every route and its parameters are correctly interpreted and represented. class AppRouteInformationParser extends RouteInformationParser { const AppRouteInformationParser(); + /// Parses the given [RouteInformation] into an [AppRouteConfiguration]. + /// + /// This method extracts the URI from the [RouteInformation], identifies the route path using + /// [AppRouteUriTemplate.findFromUri], and captures any query parameters. The resulting + /// [AppRouteConfiguration] represents the application's navigation state based on the provided URI, + /// facilitating the app's response to changes in the browser's address bar. @override Future parseRouteInformation( RouteInformation routeInformation, ) async { final uri = routeInformation.uri; - final queryParams = uri.queryParameters; - final appRoutePath = - AppRoutePath.findFromPageName(uri.path, queryParams.keys); return AppRouteConfiguration( - appRoutePath: appRoutePath, - queryParams: queryParams, + appRouteUriTemplate: AppRouteUriTemplate.findFromUri(uri), + queryParams: uri.queryParameters, ); } + /// Restores the [RouteInformation] from a given [AppRouteConfiguration]. + /// + /// This method converts the application's current navigation state back into a URI, + /// which can be used to update the browser's address bar, ensuring consistency between + /// the app's internal state and the URL displayed to the user. This helps maintain + /// correct navigation behavior and supports deep linking, back/forward navigation, and + /// sharing of accurate URLs. @override RouteInformation restoreRouteInformation( - AppRouteConfiguration configuration) { + AppRouteConfiguration configuration, + ) { return RouteInformation(uri: configuration.toUri()); } } diff --git a/coffee_maker_navigator_2/lib/app/router/view/app_route_observer.dart b/coffee_maker_navigator_2/lib/app/router/view/app_route_observer.dart index 66329fec..4235d7a1 100644 --- a/coffee_maker_navigator_2/lib/app/router/view/app_route_observer.dart +++ b/coffee_maker_navigator_2/lib/app/router/view/app_route_observer.dart @@ -2,6 +2,10 @@ import 'package:coffee_maker_navigator_2/app/router/entities/app_route_page.dart import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/material.dart'; +/// The [AppRouteObserver] class extends the [RouteObserver] class, allowing it to observe and +/// respond to changes in the app's navigation stack. This is particularly useful for setting +/// system UI Overlay style when navigation changes occur. For example the app navigation bar +/// color depends on the presence of the bottom navigation bar in the current route. class AppRouteObserver extends RouteObserver> { final ColorScheme colorScheme; diff --git a/coffee_maker_navigator_2/lib/app/router/view/app_router_delegate.dart b/coffee_maker_navigator_2/lib/app/router/view/app_router_delegate.dart index 0bec3da5..6d4031b9 100644 --- a/coffee_maker_navigator_2/lib/app/router/view/app_router_delegate.dart +++ b/coffee_maker_navigator_2/lib/app/router/view/app_router_delegate.dart @@ -1,11 +1,34 @@ -import 'dart:async'; - import 'package:coffee_maker_navigator_2/app/router/entities/app_route_configuration.dart'; -import 'package:coffee_maker_navigator_2/app/router/entities/app_route_path.dart'; import 'package:coffee_maker_navigator_2/app/router/view/app_route_observer.dart'; import 'package:coffee_maker_navigator_2/app/router/view_model/router_view_model.dart'; import 'package:flutter/material.dart'; +/// The [AppRouterDelegate] is the core component of the navigation system, specifically designed +/// to work with the Navigator 2.0 API. It extends the [RouterDelegate] class, which +/// is responsible for building the [Navigator] widget every time the app navigation state changes. +/// +/// This class integrates with the [RouterViewModel], which holds and manages the list of pages +/// and the state of the navigation, making it easier to handle dynamic changes in the navigation +/// stack. +/// +/// **Key Responsibilities:** +/// - **Building the Navigation Stack:** The `build` method constructs the [Navigator] widget, +/// which is the core of the navigation system. It defines the list of pages to display, +/// how to handle pop actions, and observers for navigation events. +/// - **Handling Pop Actions:** The `onPopPage` method is used to handle page pops from the +/// [Navigator]. It integrates with the [RouterViewModel] to manage state changes when a +/// page is popped specifically pop actions initiated by the operating system (e.g., Android's +/// back gesture or hardware button). +/// - **Synchronizing Navigation State:** The `currentConfiguration` getter returns the +/// current navigation state as an [AppRouteConfiguration], which is used to update the +/// browser's URL or sync with the app's state. The `setNewRoutePath` method allows the app to +/// respond to changes in the route configuration, such as when a new URL is entered in the +/// browser or deep links are used. +/// - **Listening to State Changes:** The constructor sets up listeners to react to changes +/// in the [RouterViewModel], such as updates to the pages or visibility of specific tabs. +/// These listeners trigger the `notifyListeners` method, ensuring that the [Navigator] widget +/// is rebuilt whenever relevant state changes occur. + class AppRouterDelegate extends RouterDelegate with ChangeNotifier { GlobalKey navigatorKey = GlobalKey(); @@ -13,14 +36,21 @@ class AppRouterDelegate extends RouterDelegate final RouterViewModel routerViewModel; AppRouterDelegate(this.routerViewModel) { - routerViewModel.navigationStack.addListener(notifyListeners); + // Setting up listeners to monitor changes in the navigation state. + // Listens to changes in the list of pages and the visibility of specific tabs. + // When changes are detected, it triggers a rebuild for the Navigator widget. + Listenable.merge([ + routerViewModel.pages, + routerViewModel.visibleOrderScreenNavBarTab, + ]).addListener(notifyListeners); } @override Widget build(BuildContext context) { return Navigator( key: navigatorKey, - pages: routerViewModel.navigationStack.value.pages, + pages: routerViewModel.pages + .value, // The list of pages defining the current navigation stack. onPopPage: (route, result) { routerViewModel.onPagePoppedImperatively(); return route.didPop(result); @@ -29,28 +59,25 @@ class AppRouterDelegate extends RouterDelegate ); } - /// The pops caused by Android swipe gesture and hardware button - /// is handled in here instead of [Navigator]'s onPopPage callback. + /// Handles pop actions initiated by the operating system (e.g., back gestures or hardware + /// buttons). This method ensures that such interactions are managed consistently with the + /// app's navigation logic. @override Future popRoute() { - final currentContext = navigatorKey.currentContext; - if (currentContext != null) { - return routerViewModel.onPagePoppedWithOperatingSystemIntent(); - } - - return Future.value(false); + return routerViewModel.onPagePoppedWithOperatingSystemIntent(); } @override AppRouteConfiguration get currentConfiguration { - final lastPage = routerViewModel.navigationStack.value.lastPage; - return lastPage.configuration ?? - const AppRouteConfiguration(appRoutePath: AppRoutePath.bootstrap); + // Returns the current route configuration, used to update the browser's URL + // and keep it in sync with the application's state. + return routerViewModel.onUriRestoration(); } @override - // ignore: no-empty-block, nothing to do Future setNewRoutePath(AppRouteConfiguration configuration) async { - routerViewModel.onNewRoutePathSet(configuration); + // Updates the navigation stack based on a new route configuration, allowing + // the application to respond to changes such as URL updates or deep links. + routerViewModel.onNewUriParsed(configuration); } } diff --git a/coffee_maker_navigator_2/lib/app/router/view_model/router_view_model.dart b/coffee_maker_navigator_2/lib/app/router/view_model/router_view_model.dart index b12f42ca..0d086e6c 100644 --- a/coffee_maker_navigator_2/lib/app/router/view_model/router_view_model.dart +++ b/coffee_maker_navigator_2/lib/app/router/view_model/router_view_model.dart @@ -1,24 +1,70 @@ import 'dart:async'; import 'package:coffee_maker_navigator_2/app/auth/domain/auth_service.dart'; -import 'package:coffee_maker_navigator_2/app/router/entities/app_navigation_stack.dart'; import 'package:coffee_maker_navigator_2/app/router/entities/app_route_configuration.dart'; import 'package:coffee_maker_navigator_2/app/router/entities/app_route_page.dart'; -import 'package:coffee_maker_navigator_2/app/router/entities/app_route_path.dart'; +import 'package:coffee_maker_navigator_2/app/router/entities/app_route_uri_template.dart'; import 'package:coffee_maker_navigator_2/app/ui/widgets/app_navigation_drawer.dart'; import 'package:coffee_maker_navigator_2/features/onboarding/domain/onboarding_service.dart'; import 'package:coffee_maker_navigator_2/features/orders/domain/entities/coffee_maker_step.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; +/// The `RouterViewModel` class acts as the central manager for navigation state within the application. +/// It serves as the bridge between the app's state management and the navigation system, providing +/// a clear and structured way to handle navigation events, manage the navigation stack, and synchronize +/// the app's state with the browser's URL in a declarative manner using Flutter's Navigator 2.0 API. +/// +/// The primary role of `RouterViewModel` is to manage the current navigation state of the +/// application. It maintains a stack of pages, represented by [AppRoutePage] objects, which +/// define the current route and any additional navigation state (e.g., query parameters). This +/// stack is directly tied to the app's navigation flow, making it easier to control and monitor +/// state changes as users navigate through the app. +/// +/// The class interacts with services such as [AuthService] and [OnboardingService] to +/// determine the navigation flow based on user authentication state and onboarding status. By +/// reacting to changes in these services, `RouterViewModel` dynamically adjusts the navigation +/// stack, ensuring that the app responds appropriately to state changes like login/logout +/// or completion of onboarding. +/// +/// By using [ValueNotifier]s to manage the list of pages and visible tabs, `RouterViewModel` +/// provides a declarative approach to navigation. This makes the navigation logic more +/// predictable, easier to reason about, and simplifies the process of syncing the UI with the +/// underlying state. +/// +/// **Exhaustiveness and Robustness:** +/// By using a well-defined structure for handling different route configurations and navigation events, +/// `RouterViewModel` ensures exhaustiveness in route handling. Every potential route and navigation event +/// is accounted for, minimizing the risk of unhandled cases and navigation errors. This is achieved by: +/// +/// - **Explicit Route Handling:** Methods like [onNewUriParsed], [onUriRestoration], +/// [onPagePoppedWithOperatingSystemIntent] explicitly handle all defined routes, using +/// exhaustive switch cases based on [AppRouteUriTemplate]. This guarantees that the app can +/// handle any valid route configuration, ensuring robustness in the face of various navigation scenarios. +/// - **Centralized Navigation Logic:** By centralizing navigation logic within `RouterViewModel`, the app reduces +/// complexity and avoids scattered navigation handling, making the codebase easier to maintain and extend. +/// - **Integration with Navigator 2.0:** The class integrates seamlessly with Navigator 2.0, providing methods +/// to update the navigation state based on URL changes and to reflect navigation state changes in the URL. +/// **Design Considerations:** +/// - **Scalability:** `RouterViewModel` is designed to scale with the application's needs. As new features and +/// routes are added, they can be integrated into the existing structure, ensuring that the app's navigation +/// remains cohesive and manageable. +/// - **Singleton Usage Pattern:** Typically, `RouterViewModel` is used as a singleton, ensuring a single source +/// of truth for navigation state across the application. However, methods like `dispose` are implemented to +/// support scenarios where the singleton pattern may change, adhering to best practices for resource management. class RouterViewModel { final AuthService authService; final OnboardingService onboardingService; - late final ValueNotifier _navigationStack = - ValueNotifier(const AppNavigationStack( - pages: [BootstrapRoutePage()], - )); + late final ValueNotifier> _pages = + ValueNotifier([const BootstrapRoutePage()]); - ValueListenable get navigationStack => _navigationStack; + ValueListenable> get pages => _pages; + + final ValueNotifier _visibleOrderScreenNavBarTab = + ValueNotifier(CoffeeMakerStep.grind); + + ValueListenable get visibleOrderScreenNavBarTab => + _visibleOrderScreenNavBarTab; RouterViewModel({ required this.authService, @@ -27,21 +73,43 @@ class RouterViewModel { authService.authStateListenable.addListener(_authStateChangeSubscription); } + /// Cleans up and releases all resources when the view model is no longer needed. + /// + /// This method is designed to be called when the [RouterViewModel] is being disposed of, + /// ensuring that all event listeners and other disposable resources are properly released to + /// prevent memory leaks. + /// + /// However, given that the [RouterViewModel] typically exists as a singleton, the likelihood + /// of this method being invoked is low, but it is implemented to adhere to best practices + /// for resource management and to ensure proper functionality in case the singleton usage + /// pattern changes. void dispose() { authService.authStateListenable .removeListener(_authStateChangeSubscription); - _navigationStack.dispose(); + _pages.dispose(); } + /// Handles navigation actions triggered from the [AppNavigationDrawer] widget by updating the + /// page stack based on the selected destination. + /// + /// This method manages navigation state transitions when a user selects a destination from a + /// navigation drawer, ensuring the appropriate pages are loaded. + /// + /// Parameters: + /// - destination: The [AppNavigationDrawerDestination] enum value indicating which drawer + /// menu item was selected by the user. void onDrawerDestinationSelected( AppNavigationDrawerDestination destination, ) { switch (destination) { case AppNavigationDrawerDestination.ordersScreen: - _navigationStack.value = AppNavigationStack.ordersStack(); + _pages.value = [OrdersRoutePage(_visibleOrderScreenNavBarTab)]; break; case AppNavigationDrawerDestination.tutorialsScreen: - _navigationStack.value = AppNavigationStack.tutorialsStack(); + _pages.value = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + const TutorialsRoutePage(), + ]; break; case AppNavigationDrawerDestination.logOut: authService.logOut(); @@ -49,19 +117,29 @@ class RouterViewModel { } } + /// Responds to route pop actions that are initiated imperatively using Flutter's Navigator + /// widget. This method serves as a centralized handler for imperative pop requests, allowing for + /// using imperative navigation methods together with the declarative navigation system of the + /// Navigator 2.0. void onPagePoppedImperatively() { _popPage(); } - /// The pops caused by Android swipe gesture and hardware button - /// is handled in here instead of Navigator's onPopPage callback. - /// Returning false will cause the entire app to be popped. + /// Handles page pop actions initiated by the operating system, such as the Android back swipe gesture + /// or the hardware back button. + /// + /// Returning `false` from this method will result in the entire application being closed, as it + /// indicates that no further navigation actions can be handled internally by the app. + /// + /// Returns: + /// - [Future]: A boolean value wrapped in a Future that indicates whether the pop action + /// has been successfully handled within the app (`true`) or if it should result in the app + /// being exited (`false`). Future onPagePoppedWithOperatingSystemIntent() { - switch (_navigationStack.value.lastPage) { + switch (_pages.value.last) { case BootstrapRoutePage(): case LoginRoutePage(): case OrdersRoutePage(): - // false means the entire app will be popped. return Future.value(false); case SingleTutorialRoutePage(): case AddWaterRoutePage(): @@ -70,13 +148,128 @@ class RouterViewModel { case OnboardingModalRoutePage(): case GrindCoffeeModalRoutePage(): _popPage(); - // true means the current page will be popped. return Future.value(true); } } - void onCloseOnboardingModalSheet() { - _popPage(); + /// Handles the update of the routing URL (visible on the Browser address bar) when the app + /// navigation state changes. + /// + /// This method is triggered by changes to either the [pages] list or the + /// [visibleOrderScreenNavBarTab]. It constructs a new [AppRouteConfiguration] + /// based on the last page in the navigation stack. This configuration includes both the + /// path and any associated query parameters. The newly constructed configuration will be + /// utilized by the [RouteInformationParser] to reflect the changes in the browser's URL. + /// + /// Returns: + /// - [AppRouteConfiguration]: The route configuration for the current state. + AppRouteConfiguration onUriRestoration() { + final lastPage = _pages.value.last; + return AppRouteConfiguration( + appRouteUriTemplate: lastPage.appRouteUriTemplate, + queryParams: lastPage.queryParams ?? {}, + ); + } + + /// Responds to the parsing of a new URL route by the [RouteInformationParser] and returns sets + /// the new navigation stack defined by the [pages] list based on the provided + /// [AppRouteConfiguration]. + /// + /// This method is called when a new route URI is detected, typically initiated by the user + /// modifying the URL in the browser's address bar. It instructs the [RouterDelegate] to + /// reconstruct the navigation stack based on the specified [AppRouteConfiguration]. The + /// configuration provides the necessary details to accurately rebuild the stack, reflecting + /// the new intended navigation state. + /// + /// Parameters: + /// - configuration: The [AppRouteConfiguration] that encapsulates the desired route details. + void onNewUriParsed(AppRouteConfiguration configuration) { + final queryParams = configuration.queryParams; + final selectedCoffeeOrderId = + queryParams[AppRouteUriTemplate.queryParamKeyId]; + final orderScreenNavBarTab = + queryParams[AppRouteUriTemplate.queryParamKeyOrderScreenTab]; + if (orderScreenNavBarTab != null) { + _visibleOrderScreenNavBarTab.value = + CoffeeMakerStep.fromQueryParameter(orderScreenNavBarTab); + } + + late List newPath; + + switch (configuration.appRouteUriTemplate) { + case AppRouteUriTemplate.login: + newPath = [ + const LoginRoutePage(), + ]; + break; + case AppRouteUriTemplate.orders: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + ]; + break; + case AppRouteUriTemplate.tutorials: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + const TutorialsRoutePage(), + ]; + break; + case AppRouteUriTemplate.grindTutorial: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + const TutorialsRoutePage(), + SingleTutorialRoutePage(CoffeeMakerStep.grind), + ]; + break; + case AppRouteUriTemplate.waterTutorial: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + const TutorialsRoutePage(), + SingleTutorialRoutePage(CoffeeMakerStep.addWater), + ]; + break; + case AppRouteUriTemplate.readyTutorial: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + const TutorialsRoutePage(), + SingleTutorialRoutePage(CoffeeMakerStep.ready), + ]; + break; + case AppRouteUriTemplate.grindCoffeeModal: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + if (selectedCoffeeOrderId != null) + GrindCoffeeModalRoutePage(coffeeOrderId: selectedCoffeeOrderId), + ]; + break; + case AppRouteUriTemplate.addWater: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + if (selectedCoffeeOrderId != null) + AddWaterRoutePage(selectedCoffeeOrderId), + ]; + break; + case AppRouteUriTemplate.readyCoffeeModal: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + if (selectedCoffeeOrderId != null) + ReadyCoffeeModalRoutePage(coffeeOrderId: selectedCoffeeOrderId), + ]; + break; + case AppRouteUriTemplate.onboarding: + newPath = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + const OnboardingModalRoutePage(), + ]; + break; + case AppRouteUriTemplate.bootstrap: + case AppRouteUriTemplate.unknown: + final isLoggedIn = authService.authStateListenable.value ?? false; + newPath = isLoggedIn + ? [OrdersRoutePage(_visibleOrderScreenNavBarTab)] + : [const LoginRoutePage()]; + break; + } + _pages.value = newPath; } void onUserRequestedTutorialFromOnboardingModal() { @@ -88,114 +281,49 @@ class RouterViewModel { _pushPage(SingleTutorialRoutePage(coffeeMakerStep)); } - void onGrindCoffeeStepSelected(String id) { - _pushPage(GrindCoffeeModalRoutePage(coffeeOrderId: id)); - } - - void onGrindCoffeeCompleted() { - _popPage(); - } - - void onAddWaterCoffeeStepSelected(String coffeeOrderId) { - _pushPage(AddWaterRoutePage(coffeeOrderId)); + void onOrderStepStarted(String id, CoffeeMakerStep step) { + switch (step) { + case CoffeeMakerStep.grind: + _pushPage(GrindCoffeeModalRoutePage(coffeeOrderId: id)); + break; + case CoffeeMakerStep.addWater: + _pushPage(AddWaterRoutePage(id)); + break; + case CoffeeMakerStep.ready: + _pushPage(ReadyCoffeeModalRoutePage(coffeeOrderId: id)); + break; + } } - void onAddWaterStepCompleted() { + void onOrderStepCompleted() { _popPage(); } - void onReadyCoffeeStepSelected(String id) { - _pushPage(ReadyCoffeeModalRoutePage(coffeeOrderId: id)); - } - - void onReadyCoffeeStepCompleted() { - _popPage(); + void onOrderScreenNavBarTabSelected(CoffeeMakerStep selectedStep) { + _visibleOrderScreenNavBarTab.value = selectedStep; } void _authStateChangeSubscription() { final isLoggedIn = authService.authStateListenable.value ?? false; if (isLoggedIn) { final shouldShowOnboardingModal = !onboardingService.isTutorialShown(); - _navigationStack.value = AppNavigationStack.ordersStack( - shouldShowOnboardingModal: shouldShowOnboardingModal, - ); + _pages.value = [ + OrdersRoutePage(_visibleOrderScreenNavBarTab), + if (shouldShowOnboardingModal) const OnboardingModalRoutePage(), + ]; } else { - _navigationStack.value = AppNavigationStack.loginStack(); + _pages.value = [const LoginRoutePage()]; } } void _pushPage(AppRoutePage page) { - final currentPages = _navigationStack.value.pages; - _navigationStack.value = AppNavigationStack( - pages: List.of(currentPages)..add(page), - ); + _pages.value = List.of(_pages.value)..add(page); } void _popPage() { - final pageCount = _navigationStack.value.pages.length; + final pageCount = _pages.value.length; if (pageCount > 1) { - final poppedList = _navigationStack.value.pages.sublist(0, pageCount - 1); - _navigationStack.value = AppNavigationStack(pages: poppedList); - } - } - - void onNewRoutePathSet(AppRouteConfiguration configuration) { - final coffeeOrderId = configuration.queryParams[AppRoutePath.queryParamId]; - late AppNavigationStack updatedStack; - - switch (configuration.appRoutePath) { - case AppRoutePath.bootstrap: - updatedStack = AppNavigationStack.bootstrapStack(); - break; - case AppRoutePath.login: - updatedStack = AppNavigationStack.loginStack(); - break; - case AppRoutePath.orders: - updatedStack = AppNavigationStack.ordersStack(); - break; - case AppRoutePath.tutorials: - updatedStack = AppNavigationStack.tutorialsStack(); - break; - case AppRoutePath.grindTutorial: - updatedStack = AppNavigationStack.tutorialsStack(CoffeeMakerStep.grind); - break; - case AppRoutePath.waterTutorial: - updatedStack = - AppNavigationStack.tutorialsStack(CoffeeMakerStep.addWater); - break; - case AppRoutePath.readyTutorial: - updatedStack = AppNavigationStack.tutorialsStack(CoffeeMakerStep.ready); - break; - case AppRoutePath.grindCoffeeModal: - updatedStack = AppNavigationStack.ordersStack( - coffeeOrderId: coffeeOrderId, - step: CoffeeMakerStep.grind, - ); - break; - case AppRoutePath.addWater: - updatedStack = AppNavigationStack.ordersStack( - coffeeOrderId: coffeeOrderId, - step: CoffeeMakerStep.addWater, - ); - break; - case AppRoutePath.readyCoffeeModal: - updatedStack = AppNavigationStack.ordersStack( - coffeeOrderId: coffeeOrderId, - step: CoffeeMakerStep.ready, - ); - break; - case AppRoutePath.unknown: - final isLoggedIn = authService.authStateListenable.value ?? false; - updatedStack = isLoggedIn - ? AppNavigationStack.ordersStack() - : AppNavigationStack.loginStack(); - break; - case AppRoutePath.onboarding: - updatedStack = AppNavigationStack.ordersStack( - shouldShowOnboardingModal: true, - ); - break; + _pages.value = _pages.value.sublist(0, pageCount - 1); } - _navigationStack.value = updatedStack; } } diff --git a/coffee_maker_navigator_2/lib/features/add_water/ui/view/add_water_screen.dart b/coffee_maker_navigator_2/lib/features/add_water/ui/view/add_water_screen.dart index b469622b..b39ae0d3 100644 --- a/coffee_maker_navigator_2/lib/features/add_water/ui/view/add_water_screen.dart +++ b/coffee_maker_navigator_2/lib/features/add_water/ui/view/add_water_screen.dart @@ -2,6 +2,7 @@ import 'package:coffee_maker_navigator_2/features/add_water/di/add_water_depende import 'package:coffee_maker_navigator_2/features/add_water/ui/view/widgets/add_water_screen_content.dart'; import 'package:coffee_maker_navigator_2/features/add_water/ui/view/widgets/add_water_step_order_not_found.dart'; import 'package:coffee_maker_navigator_2/features/add_water/ui/view_model/add_water_view_model.dart'; +import 'package:coffee_maker_navigator_2/utils/extensions/context_extensions.dart'; import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/material.dart'; import 'package:wolt_di/wolt_di.dart'; @@ -61,8 +62,12 @@ class _AddWaterScreenState extends State errorMessage: _viewModel.errorMessage, onCheckValidityPressed: _viewModel.onCheckValidityPressed, onAddWaterPressed: _viewModel.onAddWaterPressed, + onStepCompleted: context.routerViewModel.onOrderStepCompleted, ) - : const AddWaterStepOrderNotFound(), + : AddWaterStepOrderNotFound( + onOrderStepCompleted: + context.routerViewModel.onOrderStepCompleted, + ), ), ), ); diff --git a/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_content.dart b/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_content.dart index 738f9b8c..f82de8db 100644 --- a/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_content.dart +++ b/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_content.dart @@ -2,10 +2,8 @@ import 'package:coffee_maker_navigator_2/features/add_water/domain/entities/wate import 'package:coffee_maker_navigator_2/features/add_water/ui/view/widgets/add_water_screen_back_button.dart'; import 'package:coffee_maker_navigator_2/features/add_water/ui/view/widgets/add_water_screen_body.dart'; import 'package:coffee_maker_navigator_2/features/add_water/ui/view/widgets/add_water_screen_footer.dart'; -import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class AddWaterScreenContent extends StatelessWidget { const AddWaterScreenContent({ @@ -17,6 +15,7 @@ class AddWaterScreenContent extends StatelessWidget { required this.onAddWaterPressed, required this.isReadyToAddWater, required this.errorMessage, + required this.onStepCompleted, }); final void Function(String) onWaterQuantityUpdated; @@ -26,6 +25,7 @@ class AddWaterScreenContent extends StatelessWidget { final ValueListenable errorMessage; final VoidCallback onCheckValidityPressed; final VoidCallback onAddWaterPressed; + final VoidCallback onStepCompleted; @override Widget build(BuildContext context) { @@ -42,6 +42,7 @@ class AddWaterScreenContent extends StatelessWidget { errorMessage, onCheckValidityPressed, onAddWaterPressed, + onStepCompleted, ), ], ); diff --git a/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_footer.dart b/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_footer.dart index 7f1a68c8..d0a6996b 100644 --- a/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_footer.dart +++ b/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_screen_footer.dart @@ -1,5 +1,4 @@ import 'package:coffee_maker_navigator_2/features/add_water/ui/view/widgets/error_notification_widget.dart'; -import 'package:coffee_maker_navigator_2/utils/extensions/context_extensions.dart'; import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -9,7 +8,8 @@ class AddWaterScreenFooter extends StatelessWidget { this.isReadyToAddWater, this.errorMessage, this.onCheckValidity, - this.onAddWater, { + this.onAddWater, + this.onStepCompleted, { super.key, }); @@ -17,6 +17,7 @@ class AddWaterScreenFooter extends StatelessWidget { final ValueListenable errorMessage; final VoidCallback onCheckValidity; final VoidCallback onAddWater; + final VoidCallback onStepCompleted; @override Widget build(BuildContext context) { @@ -52,7 +53,7 @@ class AddWaterScreenFooter extends StatelessWidget { enabled: isEnabled, onPressed: () { onAddWater(); - context.routerViewModel.onAddWaterStepCompleted(); + onStepCompleted(); }, child: const Text('Add water'), ); diff --git a/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_step_order_not_found.dart b/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_step_order_not_found.dart index 87072b93..45d442d5 100644 --- a/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_step_order_not_found.dart +++ b/coffee_maker_navigator_2/lib/features/add_water/ui/view/widgets/add_water_step_order_not_found.dart @@ -1,9 +1,13 @@ -import 'package:coffee_maker_navigator_2/utils/extensions/context_extensions.dart'; import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/material.dart'; class AddWaterStepOrderNotFound extends StatelessWidget { - const AddWaterStepOrderNotFound({super.key}); + const AddWaterStepOrderNotFound({ + required this.onOrderStepCompleted, + super.key, + }); + + final VoidCallback onOrderStepCompleted; @override Widget build(BuildContext context) { @@ -30,7 +34,7 @@ class AddWaterStepOrderNotFound extends StatelessWidget { ), ), WoltElevatedButton( - onPressed: context.routerViewModel.onAddWaterStepCompleted, + onPressed: onOrderStepCompleted, child: const Text('Go to orders'), ), ], diff --git a/coffee_maker_navigator_2/lib/features/onboarding/ui/view/onboarding_modal_sheet_page.dart b/coffee_maker_navigator_2/lib/features/onboarding/ui/view/onboarding_modal_sheet_page.dart index c7d8b778..af5bdabd 100644 --- a/coffee_maker_navigator_2/lib/features/onboarding/ui/view/onboarding_modal_sheet_page.dart +++ b/coffee_maker_navigator_2/lib/features/onboarding/ui/view/onboarding_modal_sheet_page.dart @@ -29,7 +29,10 @@ We're excited to assist you with the orders. To ensure you get the most out of o Builder(builder: (context) { return WoltElevatedButton( onPressed: () { - context.routerViewModel.onCloseOnboardingModalSheet(); + // No need to call RouterViewModel because onPopPage method of the + // RouterDelegate will capture this and the RouterViewModel will handle + // from there. + Navigator.of(context).pop(); }, theme: WoltElevatedButtonTheme.secondary, child: const Text('Show me later'), diff --git a/coffee_maker_navigator_2/lib/features/orders/domain/entities/coffee_maker_step.dart b/coffee_maker_navigator_2/lib/features/orders/domain/entities/coffee_maker_step.dart index c7bb5b9e..b655ef1a 100644 --- a/coffee_maker_navigator_2/lib/features/orders/domain/entities/coffee_maker_step.dart +++ b/coffee_maker_navigator_2/lib/features/orders/domain/entities/coffee_maker_step.dart @@ -7,6 +7,7 @@ enum CoffeeMakerStep { actionName: 'Start grinding', assetName: '${_imagePath}_grind.jpg', tutorialTitle: 'Tips for grinding', + queryParamName: 'grind', tutorialContent: ''' Grinding is crucial for brewing as it increases the surface area of coffee beans, enhancing water's ability to extract flavors effectively. However, the grind size needs to be optimal—not too fine nor too coarse—to prevent undesirable flavors from over- or under-extraction. Experts like Sierra Yeo emphasize that the right grind can significantly impact the taste of coffee, suggesting that coffee enthusiasts consider grinding their beans at home for the freshest, most flavorful results. Additionally, the consistency and shape of the coffee grounds also play critical roles in the brewing process, influencing the overall taste of the coffee. @@ -18,6 +19,7 @@ The process of finding the perfect grind is not just about the size but also abo actionName: 'Add water', assetName: '${_imagePath}_water.jpg', tutorialTitle: 'Adding water to coffee', + queryParamName: 'water', tutorialContent: ''' Adding water to coffee involves precise considerations, significantly influencing the quality and flavor of the brew. Optimal water temperature, typically between 195°F to 205°F (about 90°C to 96°C), is crucial for effective extraction of coffee oils and flavors without causing bitterness due to over-extraction. These temperatures can vary slightly depending on the season, with adjustments made to accommodate ambient temperature effects on the brewing process. An innovative service class in the coffee maker takes these factors into account, dynamically adjusting acceptable temperature ranges based on the current coffee season to ensure optimal brewing conditions year-round. @@ -30,6 +32,7 @@ Moreover, the quality of water used is paramount. The water source's pH level an actionName: 'Ready', assetName: '${_imagePath}_ready.jpg', tutorialTitle: 'Serving coffee', + queryParamName: 'ready', tutorialContent: ''' Serving coffee, the final and perhaps the most gratifying step in the coffee-making process, is about more than merely pouring a brew into a cup. This stage is crucial for ensuring that the aroma and temperature of the coffee are optimal at the moment of enjoyment. Serving coffee at the right temperature is vital—not too hot to cause discomfort, nor too cool, which might dampen its rich flavors and aromas. Ideally, coffee should be served immediately after brewing, at a temperature between 155°F and 175°F (about 68°C to 80°C), to capture the full spectrum of flavors crafted during the brewing process. @@ -45,6 +48,7 @@ Further elevating the serving experience, some advanced coffee machines integrat required this.assetName, required this.tutorialContent, required this.tutorialTitle, + required this.queryParamName, }); final String stepName; @@ -53,4 +57,12 @@ Further elevating the serving experience, some advanced coffee machines integrat final String assetName; final String tutorialContent; final String tutorialTitle; + final String queryParamName; + + static CoffeeMakerStep fromQueryParameter(String queryParameter) { + return CoffeeMakerStep.values.firstWhere( + (element) => element.queryParamName == queryParameter, + orElse: () => CoffeeMakerStep.grind, + ); + } } diff --git a/coffee_maker_navigator_2/lib/features/orders/ui/view/orders_screen.dart b/coffee_maker_navigator_2/lib/features/orders/ui/view/orders_screen.dart index 3a45195d..318f924a 100644 --- a/coffee_maker_navigator_2/lib/features/orders/ui/view/orders_screen.dart +++ b/coffee_maker_navigator_2/lib/features/orders/ui/view/orders_screen.dart @@ -2,13 +2,12 @@ import 'package:coffee_maker_navigator_2/features/orders/di/orders_dependency_co import 'package:coffee_maker_navigator_2/features/orders/domain/entities/coffee_maker_step.dart'; import 'package:coffee_maker_navigator_2/features/orders/ui/view/widgets/order_screen_content.dart'; import 'package:coffee_maker_navigator_2/features/orders/ui/view_model/orders_screen_view_model.dart'; +import 'package:coffee_maker_navigator_2/utils/extensions/context_extensions.dart'; import 'package:flutter/material.dart'; import 'package:wolt_di/wolt_di.dart'; class OrdersScreen extends StatefulWidget { - const OrdersScreen(this.destinationBottomNavBarTab, {super.key}); - - final CoffeeMakerStep? destinationBottomNavBarTab; + const OrdersScreen({super.key}); @override State createState() => _OrdersScreenState(); @@ -24,8 +23,7 @@ class _OrdersScreenState extends State void initState() { super.initState(); viewModel = DependencyInjector.container(context) - .createViewModel() - ..onInit(widget.destinationBottomNavBarTab); + .createViewModel(); } @override @@ -36,10 +34,18 @@ class _OrdersScreenState extends State @override Widget build(BuildContext context) { + final routerViewModel = context.routerViewModel; + return OrderScreenContent( - selectedStepListenable: viewModel.selectedBottomNavBarItem, + selectedNavBarTabListenable: routerViewModel.visibleOrderScreenNavBarTab, + onNavBarItemSelected: routerViewModel.onOrderScreenNavBarTabSelected, groupedCoffeeOrders: viewModel.groupedCoffeeOrders, - onBottomNavBarItemSelected: viewModel.onBottomNavBarItemSelected, + onGrindCoffeeStepSelected: (id) => + routerViewModel.onOrderStepStarted(id, CoffeeMakerStep.grind), + onAddWaterCoffeeStepSelected: (id) => + routerViewModel.onOrderStepStarted(id, CoffeeMakerStep.addWater), + onReadyCoffeeStepSelected: (id) => + routerViewModel.onOrderStepStarted(id, CoffeeMakerStep.ready), ); } } diff --git a/coffee_maker_navigator_2/lib/features/orders/ui/view/widgets/order_screen_content.dart b/coffee_maker_navigator_2/lib/features/orders/ui/view/widgets/order_screen_content.dart index 674df4f0..43cc6c58 100644 --- a/coffee_maker_navigator_2/lib/features/orders/ui/view/widgets/order_screen_content.dart +++ b/coffee_maker_navigator_2/lib/features/orders/ui/view/widgets/order_screen_content.dart @@ -4,7 +4,6 @@ import 'package:coffee_maker_navigator_2/features/orders/domain/entities/grouped import 'package:coffee_maker_navigator_2/features/orders/ui/view/widgets/coffee_order_list_view_for_step.dart'; import 'package:coffee_maker_navigator_2/features/orders/ui/view/widgets/orders_screen_bottom_navigation_bar.dart'; import 'package:coffee_maker_navigator_2/features/orders/ui/widgets/top_bar.dart'; -import 'package:coffee_maker_navigator_2/utils/extensions/context_extensions.dart'; import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -20,14 +19,20 @@ typedef OnOrderScreenBottomNavBarItemSelected = void Function( class OrderScreenContent extends StatelessWidget { const OrderScreenContent({ super.key, - required this.selectedStepListenable, + required this.selectedNavBarTabListenable, required this.groupedCoffeeOrders, - required this.onBottomNavBarItemSelected, + required this.onNavBarItemSelected, + required this.onGrindCoffeeStepSelected, + required this.onAddWaterCoffeeStepSelected, + required this.onReadyCoffeeStepSelected, }); - final ValueListenable selectedStepListenable; + final ValueListenable selectedNavBarTabListenable; final ValueListenable groupedCoffeeOrders; - final OnOrderScreenBottomNavBarItemSelected onBottomNavBarItemSelected; + final OnOrderScreenBottomNavBarItemSelected onNavBarItemSelected; + final OnCoffeeOrderUpdate onGrindCoffeeStepSelected; + final OnCoffeeOrderUpdate onAddWaterCoffeeStepSelected; + final OnCoffeeOrderUpdate onReadyCoffeeStepSelected; @override Widget build(BuildContext context) { @@ -36,7 +41,7 @@ class OrderScreenContent extends StatelessWidget { child: Scaffold( body: SafeArea( child: ValueListenableBuilder( - valueListenable: selectedStepListenable, + valueListenable: selectedNavBarTabListenable, builder: (context, selectedTab, _) { return Column( children: [ @@ -45,16 +50,13 @@ class OrderScreenContent extends StatelessWidget { child: ValueListenableBuilder( valueListenable: groupedCoffeeOrders, builder: (context, orders, _) { - final routerViewModel = context.routerViewModel; return CoffeeOrderListViewForStep( groupedCoffeeOrders: orders, selectedBottomNavBarItem: selectedTab, - onGrindCoffeeStepSelected: - routerViewModel.onGrindCoffeeStepSelected, + onGrindCoffeeStepSelected: onGrindCoffeeStepSelected, onAddWaterCoffeeStepSelected: - routerViewModel.onAddWaterCoffeeStepSelected, - onReadyCoffeeStepSelected: - routerViewModel.onReadyCoffeeStepSelected, + onAddWaterCoffeeStepSelected, + onReadyCoffeeStepSelected: onReadyCoffeeStepSelected, ); }, ), @@ -67,8 +69,8 @@ class OrderScreenContent extends StatelessWidget { drawer: const AppNavigationDrawer(selectedIndex: 0), bottomNavigationBar: OrdersScreenBottomNavigationBar( groupedCoffeeOrders, - onBottomNavBarItemSelected, - selectedStepListenable, + onNavBarItemSelected, + selectedNavBarTabListenable, ), ), ); diff --git a/coffee_maker_navigator_2/lib/features/orders/ui/view_model/orders_screen_view_model.dart b/coffee_maker_navigator_2/lib/features/orders/ui/view_model/orders_screen_view_model.dart index 90320065..8c37acf4 100644 --- a/coffee_maker_navigator_2/lib/features/orders/ui/view_model/orders_screen_view_model.dart +++ b/coffee_maker_navigator_2/lib/features/orders/ui/view_model/orders_screen_view_model.dart @@ -19,26 +19,10 @@ class OrdersScreenViewModel { ValueListenable get groupedCoffeeOrders => _groupedCoffeeOrders; - final ValueNotifier _selectedBottomNavBarItem = - ValueNotifier(CoffeeMakerStep.grind); - - ValueListenable get selectedBottomNavBarItem => - _selectedBottomNavBarItem; - - void onInit(CoffeeMakerStep? initialNavBarItem) { - if (initialNavBarItem != null) { - _selectedBottomNavBarItem.value = initialNavBarItem; - } - } - void dispose() { _ordersService.orders.removeListener(_onOrdersReceived); } - void onBottomNavBarItemSelected(CoffeeMakerStep selectedStep) { - _selectedBottomNavBarItem.value = selectedStep; - } - void onOrderStatusChange(String orderId, [CoffeeMakerStep? newStep]) { _ordersService.updateOrder(orderId, newStep); } diff --git a/demo_ui_components/lib/src/theme_data/app_theme_data.dart b/demo_ui_components/lib/src/theme_data/app_theme_data.dart index 4b59c66c..fa4f6487 100644 --- a/demo_ui_components/lib/src/theme_data/app_theme_data.dart +++ b/demo_ui_components/lib/src/theme_data/app_theme_data.dart @@ -1,6 +1,5 @@ import 'package:demo_ui_components/demo_ui_components.dart'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; class AppThemeData { static const navigationDrawerIconSize = 24.0;