diff --git a/CHANGELOG.md b/CHANGELOG.md index 44014988..3bfc0be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 7.0.3 + +- **Customizable route transitions.** `route(transition:)` and the new app-wide + `ModularApp(defaultTransition:)` now accept any `PageTransition`, an open + contract that builds the route's `Page`. Three ways to supply one: + - the **`TransitionType`** presets (`material`, `fade`, `none`) — each value + now *is* a `PageTransition`, so existing `transition: TransitionType.fade` + keeps working; + - **`CustomTransition`** — the inline convenience: pass a + `transitionsBuilder` (same signature as `PageRouteBuilder`) and optionally + tune `duration`/`reverseDuration`/`opaque`/`barrierColor`/ + `barrierDismissible`/`fullscreenDialog`; Modular still owns the `Page`; + - implement **`PageTransition`** yourself for full control of the `Page` + (e.g. a `CupertinoPage` with swipe-back, a `fullscreenDialog`, shared-axis + from the `animations` package). +- **App-wide default.** `ModularApp.defaultTransition` (default + `TransitionType.material`) applies to every route that doesn't declare its + own. Precedence: route-local → app default → `material`. `route(transition:)` + now defaults to `null` (inherit the app default) instead of forcing + `material`. + ## 7.0.2 - **`context.select(selector)`** — the method-based twin of the `Selector` diff --git a/doc/docs/flutter_modular/navigation.md b/doc/docs/flutter_modular/navigation.md index 0a0d152f..ed5429a6 100644 --- a/doc/docs/flutter_modular/navigation.md +++ b/doc/docs/flutter_modular/navigation.md @@ -32,7 +32,7 @@ c.route( void Function(Scoped scoped)? provide, // page-scoped state — see State management void Function(ModularContext c)? children, // nested routes — see Nested routes List? guards, // redirects — see below - TransitionType transition, // material | fade | none + PageTransition? transition, // preset, CustomTransition, or your own — see below }); ``` @@ -175,13 +175,78 @@ gate authenticated areas, or to redirect unknown paths to a 404 page. ## Transitions -Set a route's page transition with `transition:`: +A route's page transition is any **`PageTransition`** — the contract that turns a +route into the `Page` pushed on the stack. There are three ways to supply one, +from simplest to most powerful. + +### 1. Presets + +`TransitionType` ships three ready-made transitions, and each value **is** a +`PageTransition`: ```dart c.route('/details', transition: TransitionType.fade, child: ...); ``` -`TransitionType` is `material` (the default), `fade`, or `none` (instant). +`TransitionType` is `material`, `fade`, or `none` (instant). + +### 2. `CustomTransition` — bring your own animation + +When you just want a different animation, `CustomTransition` builds the `Page` for +you; you supply the `transitionsBuilder` (same signature as +`PageRouteBuilder.transitionsBuilder`) and, optionally, `duration` and a few route +flags (`reverseDuration`, `opaque`, `barrierColor`, `barrierDismissible`, +`fullscreenDialog`): + +```dart +c.route('/details/:id', + transition: CustomTransition( + duration: const Duration(milliseconds: 250), + transitionsBuilder: (context, animation, secondary, child) => SlideTransition( + position: animation.drive( + Tween(begin: const Offset(1, 0), end: Offset.zero), + ), + child: child, + ), + ), + child: (ctx, state) => DetailsPage(id: state['id']!)); +``` + +### 3. Implement `PageTransition` — own the whole `Page` + +For full control — a `CupertinoPage` with interactive swipe-back, a +`fullscreenDialog`, a custom barrier, shared-axis from the `animations` package — +implement `PageTransition` and return whatever `Page` you like: + +```dart +class SharedAxisTransition extends PageTransition { + const SharedAxisTransition(); + + @override + Page buildPage(LocalKey key, Widget child) => + // any Page you want — e.g. CupertinoPage, a custom PageRouteBuilder, … + CupertinoPage(key: key, child: child); +} + +c.route('/profile', transition: const SharedAxisTransition(), child: ...); +``` + +### An app-wide default + +Set a fallback transition once on `ModularApp.defaultTransition`; every route that +doesn't declare its own inherits it. Precedence is **route-local → app default → +`material`**: + +```dart +ModularApp( + module: appModule, + defaultTransition: TransitionType.fade, // every route fades unless it overrides + child: const AppRoot(), +); +``` + +A route's own `transition:` always wins over the app default; leave it unset to +inherit. With no `defaultTransition` set, the default is `TransitionType.material`. ## Next diff --git a/doc/docs/flutter_modular/start.md b/doc/docs/flutter_modular/start.md index 3b32218c..6eddc40e 100644 --- a/doc/docs/flutter_modular/start.md +++ b/doc/docs/flutter_modular/start.md @@ -168,9 +168,10 @@ class CounterPage extends StatelessWidget { ```dart ModularApp( module: appModule, - initialRoute: '/home', // first route when the platform reports no deep link - navigatorKey: myNavigatorKey, // imperative access from outside the tree - navigatorObservers: [myObserver], // analytics, RouteObserver, … + initialRoute: '/home', // first route when the platform reports no deep link + navigatorKey: myNavigatorKey, // imperative access from outside the tree + navigatorObservers: [myObserver], // analytics, RouteObserver, … + defaultTransition: TransitionType.fade, // app-wide fallback page transition child: const AppRoot(), ); ``` @@ -181,6 +182,9 @@ ModularApp( - **`navigatorKey`** lets you reach the root `Navigator` imperatively (e.g. to show a global dialog). A fresh key is created if you omit it. - **`navigatorObservers`** are attached to the root navigator. +- **`defaultTransition`** is the page transition every route inherits unless it sets its + own `transition:`. Defaults to `TransitionType.material`. See + [Transitions](./navigation.md#transitions). :::tip Clean URLs on the web Call `usePathUrlStrategy()` (from `package:flutter_web_plugins/url_strategy.dart`) at diff --git a/lib/flutter_modular.dart b/lib/flutter_modular.dart index cfaee1e7..9ccc9619 100644 --- a/lib/flutter_modular.dart +++ b/lib/flutter_modular.dart @@ -13,7 +13,8 @@ export 'src/module/module.dart'; export 'src/navigation/modular_navigation.dart'; export 'src/navigation/modular_router_config.dart'; export 'src/navigation/outlet.dart' show RouterOutlet, RouterOutletState; -export 'src/navigation/transition.dart' show TransitionType; +export 'src/navigation/transition.dart' + show CustomTransition, PageTransition, TransitionType; export 'src/route/modular_route.dart'; export 'src/route/route_state.dart'; export 'src/state/consumer.dart'; diff --git a/lib/src/app/modular_app.dart b/lib/src/app/modular_app.dart index 6beccb6c..69e84fde 100644 --- a/lib/src/app/modular_app.dart +++ b/lib/src/app/modular_app.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../module/module.dart'; import '../navigation/modular_router_config.dart'; +import '../navigation/transition.dart'; import '../route/route_state.dart'; import '../state/scoped.dart'; @@ -45,6 +46,7 @@ class ModularApp extends StatefulWidget { this.initialRoute = '/', this.navigatorKey, this.navigatorObservers = const [], + this.defaultTransition = TransitionType.material, super.key, }); @@ -73,6 +75,13 @@ class ModularApp extends StatefulWidget { /// for route-aware widgets, etc. final List navigatorObservers; + /// The app-wide DEFAULT page transition, applied to every route that doesn't + /// declare its own (`route(transition:)` left unset). A route's local + /// transition always wins; this is the fallback. Defaults to + /// [TransitionType.material]. Accepts any [PageTransition] — a preset, a + /// [CustomTransition], or your own implementation. + final PageTransition defaultTransition; + /// The router config built by the nearest enclosing [ModularApp]. static RouterConfig routerConfigOf(BuildContext context) { final scope = context @@ -98,6 +107,7 @@ class _ModularAppState extends State { initialRoute: widget.initialRoute, navigatorKey: widget.navigatorKey, observers: widget.navigatorObservers, + defaultTransition: widget.defaultTransition, ); @override diff --git a/lib/src/module/module.dart b/lib/src/module/module.dart index 25dce2bf..5c58ac04 100644 --- a/lib/src/module/module.dart +++ b/lib/src/module/module.dart @@ -62,7 +62,7 @@ abstract class ModularContext { void Function(Scoped scoped)? provide, void Function(ModularContext c)? children, List? guards, - TransitionType transition, + PageTransition? transition, }); /// Include another module. The mount path is [at] ?? `module.path`: @@ -237,7 +237,7 @@ class _ContextImpl implements ModularContext { void Function(Scoped scoped)? provide, void Function(ModularContext c)? children, List? guards, - TransitionType transition = TransitionType.material, + PageTransition? transition, }) { var nested = const []; if (children != null) { diff --git a/lib/src/navigation/modular_router_config.dart b/lib/src/navigation/modular_router_config.dart index dbdf8e89..a6ed9d1f 100644 --- a/lib/src/navigation/modular_router_config.dart +++ b/lib/src/navigation/modular_router_config.dart @@ -6,6 +6,7 @@ import '../route/modular_route.dart'; import '../route/route_state.dart'; import 'modular_route_information_parser.dart'; import 'modular_router_delegate.dart'; +import 'transition.dart'; /// Wires the Navigator 2.0 pieces into a [RouterConfig] for /// `MaterialApp.router(routerConfig: ...)`. @@ -20,6 +21,7 @@ RouterConfig modularRouterConfig( String initialRoute = '/', GlobalKey? navigatorKey, List observers = const [], + PageTransition defaultTransition = TransitionType.material, }) { final inj = injector ?? (AutoInjector()..commit()); return RouterConfig( @@ -29,6 +31,7 @@ RouterConfig modularRouterConfig( manager: manager, navigatorKey: navigatorKey, observers: observers, + defaultTransition: defaultTransition, ), routeInformationParser: ModularRouteInformationParser(), routeInformationProvider: PlatformRouteInformationProvider( diff --git a/lib/src/navigation/modular_router_delegate.dart b/lib/src/navigation/modular_router_delegate.dart index a1da9012..df99aeed 100644 --- a/lib/src/navigation/modular_router_delegate.dart +++ b/lib/src/navigation/modular_router_delegate.dart @@ -25,12 +25,17 @@ class ModularRouterDelegate extends RouterDelegate this.manager, GlobalKey? navigatorKey, this.observers = const [], + this.defaultTransition = TransitionType.material, }) : navigatorKey = navigatorKey ?? GlobalKey(); final RouteCollection routes; final AutoInjector injector; final ModuleManager? manager; + /// The app-wide fallback transition, applied to any route that declares none + /// (`ModularRoute.transition == null`). Set via `ModularApp.transition`. + final PageTransition defaultTransition; + /// Observers attached to the root [Navigator] (analytics, a `RouteObserver` /// for route-aware widgets, etc.). final List observers; @@ -299,8 +304,10 @@ class ModularRouterDelegate extends RouterDelegate params: const {}, arguments: entry.state.arguments, routes: routes, + defaultTransition: defaultTransition, ); - return buildTransitionPage(chain.first.route.transition, entry.key, child); + final transition = chain.first.route.transition ?? defaultTransition; + return transition.buildPage(entry.key, child); } } diff --git a/lib/src/navigation/outlet.dart b/lib/src/navigation/outlet.dart index b37643a1..3007c84b 100644 --- a/lib/src/navigation/outlet.dart +++ b/lib/src/navigation/outlet.dart @@ -20,6 +20,7 @@ Widget buildRouteLevel({ required Map params, required RouteCollection routes, Object? arguments, + PageTransition defaultTransition = TransitionType.material, }) { final level = chain[index]; final merged = {...params, ...level.params}; @@ -39,6 +40,7 @@ Widget buildRouteLevel({ params: merged, arguments: arguments, routes: routes, + defaultTransition: defaultTransition, child: page, ); } @@ -51,6 +53,7 @@ class _OutletScope extends InheritedWidget { required this.uri, required this.params, required this.routes, + required this.defaultTransition, required super.child, this.arguments, }); @@ -63,6 +66,10 @@ class _OutletScope extends InheritedWidget { final Object? arguments; final RouteCollection routes; + /// The app-wide fallback transition, carried down so a nested outlet's pages + /// inherit it for routes that declare none. See [buildRouteLevel]. + final PageTransition defaultTransition; + static _OutletScope? of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<_OutletScope>(); @@ -301,12 +308,11 @@ class RouterOutletState extends State { params: _scope.params, arguments: entry.arguments, routes: _scope.routes, + defaultTransition: _scope.defaultTransition, ); - return buildTransitionPage( - chain[_scope.index].route.transition, - entry.key, - child, - ); + final transition = + chain[_scope.index].route.transition ?? _scope.defaultTransition; + return transition.buildPage(entry.key, child); } } diff --git a/lib/src/navigation/transition.dart b/lib/src/navigation/transition.dart index 1de87bc2..4cb6215f 100644 --- a/lib/src/navigation/transition.dart +++ b/lib/src/navigation/transition.dart @@ -1,55 +1,162 @@ import 'package:flutter/material.dart'; -/// Page transition for a route. -enum TransitionType { material, fade, none } - -/// Builds the [Page] for [child] with the requested [type]. -Page buildTransitionPage( - TransitionType type, - LocalKey key, - Widget child, -) { - switch (type) { - case TransitionType.material: - return MaterialPage(key: key, child: child); - case TransitionType.fade: - return _TransitionPage( - key: key, - child: child, - transitions: (animation, c) => - FadeTransition(opacity: animation, child: c), - ); - case TransitionType.none: - return _TransitionPage( - key: key, - child: child, - duration: Duration.zero, - transitions: (animation, c) => c, - ); +/// The contract a route transition implements: given a key and the route's +/// child, it produces the [Page] that wraps it in the navigator stack. +/// +/// Three ways to supply one to `route(transition:)` (or `ModularApp`'s +/// app-wide default): +/// - the [TransitionType] presets ([TransitionType.material], `.fade`, +/// `.none`) — each value IS a [PageTransition]; +/// - [CustomTransition] — the convenience for "I just want a different +/// animation"; Modular still owns the [Page]; +/// - implement [PageTransition] yourself for FULL control of the [Page] +/// (a `CupertinoPage` with interactive swipe-back, a `fullscreenDialog`, +/// a custom barrier, shared-axis from the `animations` package, …). +abstract class PageTransition { + const PageTransition(); + + /// Builds the [Page] for [child], stamped with [key] so the Navigator can + /// track it across rebuilds. + Page buildPage(LocalKey key, Widget child); +} + +/// Built-in transition presets. Each value is itself a [PageTransition], so +/// `transition: TransitionType.fade` keeps working while the field accepts any +/// custom [PageTransition]. +enum TransitionType implements PageTransition { + material, + fade, + none; + + @override + Page buildPage(LocalKey key, Widget child) { + switch (this) { + case TransitionType.material: + return MaterialPage(key: key, child: child); + case TransitionType.fade: + return _TransitionPage( + key: key, + child: child, + transitionsBuilder: (context, animation, secondary, c) => + FadeTransition(opacity: animation, child: c), + ); + case TransitionType.none: + return _TransitionPage( + key: key, + child: child, + duration: Duration.zero, + transitionsBuilder: (context, animation, secondary, c) => c, + ); + } } } +/// A [PageTransition] you build inline by supplying just the animation. +/// +/// Modular owns the [Page]/[PageRoute]; you provide [transitionsBuilder] (the +/// same signature as `PageRouteBuilder.transitionsBuilder`) and optionally tune +/// [duration], [reverseDuration] and the route flags. For control over the +/// [Page] itself, implement [PageTransition] directly instead. +/// +/// ```dart +/// c.route('/details/:id', +/// transition: CustomTransition( +/// duration: const Duration(milliseconds: 250), +/// transitionsBuilder: (ctx, anim, sec, child) => SlideTransition( +/// position: anim.drive( +/// Tween(begin: const Offset(1, 0), end: Offset.zero), +/// ), +/// child: child, +/// ), +/// ), +/// child: (ctx, state) => DetailsPage(id: state.params['id']!)); +/// ``` +class CustomTransition extends PageTransition { + const CustomTransition({ + required this.transitionsBuilder, + this.duration = const Duration(milliseconds: 300), + this.reverseDuration, + this.opaque = true, + this.barrierColor, + this.barrierDismissible = false, + this.fullscreenDialog = false, + }); + + /// Builds the animated wrapper around the page's content — receives the + /// primary and secondary route animations. + final RouteTransitionsBuilder transitionsBuilder; + + /// Forward (and, unless [reverseDuration] is set, reverse) transition length. + final Duration duration; + + /// Reverse transition length; falls back to [duration] when omitted. + final Duration? reverseDuration; + + /// Whether the route obscures the one below (`false` keeps it visible, e.g. + /// for a translucent overlay). + final bool opaque; + + /// Barrier color painted behind a non-[opaque] route. + final Color? barrierColor; + + /// Whether tapping the barrier pops the route. + final bool barrierDismissible; + + /// Whether to animate as a fullscreen dialog (affects the default Material + /// transition and the close affordance). + final bool fullscreenDialog; + + @override + Page buildPage(LocalKey key, Widget child) => _TransitionPage( + key: key, + child: child, + transitionsBuilder: transitionsBuilder, + duration: duration, + reverseDuration: reverseDuration, + opaque: opaque, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + fullscreenDialog: fullscreenDialog, + ); +} + +/// The [Page] backing [TransitionType.fade]/`.none` and [CustomTransition]: +/// a [PageRouteBuilder] whose content is [child] and whose animation is +/// [transitionsBuilder]. class _TransitionPage extends Page { const _TransitionPage({ required super.key, required this.child, - required this.transitions, + required this.transitionsBuilder, this.duration = const Duration(milliseconds: 300), + this.reverseDuration, + this.opaque = true, + this.barrierColor, + this.barrierDismissible = false, + this.fullscreenDialog = false, }); final Widget child; - final Widget Function(Animation animation, Widget child) transitions; + final RouteTransitionsBuilder transitionsBuilder; final Duration duration; + final Duration? reverseDuration; + final bool opaque; + final Color? barrierColor; + final bool barrierDismissible; + final bool fullscreenDialog; @override Route createRoute(BuildContext context) { return PageRouteBuilder( settings: this, transitionDuration: duration, - reverseTransitionDuration: duration, + reverseTransitionDuration: reverseDuration ?? duration, + opaque: opaque, + barrierColor: barrierColor, + barrierDismissible: barrierDismissible, + fullscreenDialog: fullscreenDialog, pageBuilder: (context, animation, secondary) => child, - transitionsBuilder: (context, animation, secondary, c) => - transitions(animation, c), + transitionsBuilder: transitionsBuilder, ); } } diff --git a/lib/src/route/modular_route.dart b/lib/src/route/modular_route.dart index 7fbd5d05..495c54f6 100644 --- a/lib/src/route/modular_route.dart +++ b/lib/src/route/modular_route.dart @@ -22,7 +22,7 @@ class ModularRoute { this.provide, this.children = const [], this.guards = const [], - this.transition = TransitionType.material, + this.transition, this.ownerTags = const [], }); @@ -31,7 +31,10 @@ class ModularRoute { final void Function(Scoped scoped)? provide; final List children; final List guards; - final TransitionType transition; + + /// This route's page transition, or `null` to inherit the app-wide default + /// (`ModularApp.transition`, itself defaulting to [TransitionType.material]). + final PageTransition? transition; /// Tags of the feature modules (mounted via `module(at:)`) that own this /// route. When the LAST active route of a tag leaves the stack, that module's diff --git a/pubspec.yaml b/pubspec.yaml index 09a39d68..a5a26a7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_modular description: Smart project structure with dependency injection and route management for Flutter. -version: 7.0.2 +version: 7.0.3 homepage: https://github.com/Flutterando/modular repository: https://github.com/Flutterando/modular issue_tracker: https://github.com/Flutterando/modular/issues diff --git a/test/transition_test.dart b/test/transition_test.dart index 93e2e0ab..353971d7 100644 --- a/test/transition_test.dart +++ b/test/transition_test.dart @@ -12,8 +12,42 @@ final tModule = createModule( }, ); +/// A user-supplied transition that owns the whole Page — proving the +/// [PageTransition] contract is open, not limited to the presets. +class _SlidePage extends PageTransition { + const _SlidePage(); + + @override + Page buildPage(LocalKey key, Widget child) => + _Slide(key: key, child: child); +} + +class _Slide extends Page { + const _Slide({required super.key, required this.child}); + final Widget child; + + @override + Route createRoute(BuildContext context) => PageRouteBuilder( + settings: this, + pageBuilder: (_, __, ___) => child, + transitionsBuilder: (_, animation, __, c) => SlideTransition( + position: animation.drive( + Tween(begin: const Offset(1, 0), end: Offset.zero), + ), + child: c, + ), + ); +} + +/// The active route hosting [finder]'s element. The material preset yields a +/// route with [MaterialRouteTransitionMixin]; the fade/none presets and any +/// [CustomTransition] yield a [PageRouteBuilder] — a clean way to tell which +/// transition actually drove the page. +ModalRoute? _routeOf(WidgetTester tester, Finder finder) => + ModalRoute.of(tester.element(finder)); + void main() { - testWidgets('a route renders with a custom (fade) transition', ( + testWidgets('a route renders with a preset (fade) transition', ( tester, ) async { final boot = bootstrapModule(tModule); @@ -22,8 +56,131 @@ void main() { ); await tester.pumpAndSettle(); - // The fade page route builds and settles to the content. expect(find.text('faded'), findsOneWidget); - expect(find.byType(FadeTransition), findsWidgets); + expect(_routeOf(tester, find.text('faded')), isA()); + }); + + testWidgets('CustomTransition drives the supplied animation', (tester) async { + final module = createModule( + register: (c) { + c.route( + '/', + transition: CustomTransition( + transitionsBuilder: (context, animation, secondary, child) => + SlideTransition( + position: animation.drive( + Tween(begin: const Offset(1, 0), end: Offset.zero), + ), + child: child, + ), + ), + child: (ctx, s) => const Text('slid'), + ); + }, + ); + final boot = bootstrapModule(module); + await tester.pumpWidget( + MaterialApp.router(routerConfig: modularRouterConfig(boot.routes)), + ); + await tester.pumpAndSettle(); + + expect(find.text('slid'), findsOneWidget); + expect(find.byType(SlideTransition), findsWidgets); + expect(_routeOf(tester, find.text('slid')), isA()); + }); + + testWidgets('a custom PageTransition implementation owns its Page', ( + tester, + ) async { + final module = createModule( + register: (c) { + c.route( + '/', + transition: const _SlidePage(), + child: (ctx, s) => const Text('own'), + ); + }, + ); + final boot = bootstrapModule(module); + await tester.pumpWidget( + MaterialApp.router(routerConfig: modularRouterConfig(boot.routes)), + ); + await tester.pumpAndSettle(); + + expect(find.text('own'), findsOneWidget); + expect(find.byType(SlideTransition), findsWidgets); + }); + + testWidgets('a route with no transition inherits the app-wide default', ( + tester, + ) async { + final module = createModule( + register: (c) { + c.route('/', child: (ctx, s) => const Text('default')); + }, + ); + final boot = bootstrapModule(module); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + defaultTransition: TransitionType.fade, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('default'), findsOneWidget); + // The global fade applied: a PageRouteBuilder, not the material route. + expect(_routeOf(tester, find.text('default')), isA()); + }); + + testWidgets('a local route transition overrides the app-wide default', ( + tester, + ) async { + final module = createModule( + register: (c) { + c.route( + '/', + transition: TransitionType.material, + child: (ctx, s) => const Text('local'), + ); + }, + ); + final boot = bootstrapModule(module); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: modularRouterConfig( + boot.routes, + defaultTransition: TransitionType.fade, + ), + ), + ); + await tester.pumpAndSettle(); + + // Local `material` wins over the global `fade`: a material route, not the + // fade preset's PageRouteBuilder. + expect(find.text('local'), findsOneWidget); + final route = _routeOf(tester, find.text('local')); + expect(route, isA()); + expect(route, isNot(isA())); + }); + + testWidgets('the default-of-default is material', (tester) async { + final module = createModule( + register: (c) { + c.route('/', child: (ctx, s) => const Text('material')); + }, + ); + final boot = bootstrapModule(module); + await tester.pumpWidget( + MaterialApp.router(routerConfig: modularRouterConfig(boot.routes)), + ); + await tester.pumpAndSettle(); + + expect(find.text('material'), findsOneWidget); + final route = _routeOf(tester, find.text('material')); + expect(route, isA()); + expect(route, isNot(isA())); }); } diff --git a/tool/docs_mcp/CHANGELOG.md b/tool/docs_mcp/CHANGELOG.md index 8717dac6..dee5c1c0 100644 --- a/tool/docs_mcp/CHANGELOG.md +++ b/tool/docs_mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.3 + +- Re-embed the navigation page with the reworked **Transitions** section: the + open `PageTransition` contract, the `CustomTransition` convenience, custom + `PageTransition` implementations, and the app-wide `ModularApp.defaultTransition` + (precedence: route-local → app default → `material`). + ## 0.2.2 - Re-embed the state-management page documenting `context.select` — the diff --git a/tool/docs_mcp/lib/src/generated/docs_data.g.dart b/tool/docs_mcp/lib/src/generated/docs_data.g.dart index 2ebfe08f..65bf15d9 100644 --- a/tool/docs_mcp/lib/src/generated/docs_data.g.dart +++ b/tool/docs_mcp/lib/src/generated/docs_data.g.dart @@ -28,7 +28,7 @@ const List docPages = [ DocPage( path: "flutter_modular/navigation.md", title: "Navigation", - markdown: "# Navigation\n\nModular's routing is built on Navigator 2.0 and matches paths like the web: static\nsegments, dynamic `:params`, query strings, nested routes and relative paths. You\nnavigate from any widget through extensions on `BuildContext`.\n\n## Declaring routes\n\nRoutes are declared on a module's context with `c.route`:\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route('/', child: (ctx, state) => const ProductListPage())\n ..route('/:id', child: (ctx, state) => ProductDetailPage(id: state['id']!));\n },\n);\n```\n\nThe full `route` signature:\n\n```dart\nc.route(\n String path, {\n required ModularWidgetBuilder child, // (BuildContext, RouteState) => Widget\n void Function(Scoped scoped)? provide, // page-scoped state — see State management\n void Function(ModularContext c)? children, // nested routes — see Nested routes\n List? guards, // redirects — see below\n TransitionType transition, // material | fade | none\n});\n```\n\n## RouteState\n\nEvery route `child` receives a `RouteState` — an immutable snapshot of the current\nroute:\n\n```dart\nc.route('/product/:id', child: (ctx, state) {\n final id = state['id']; // path param, shorthand for state.params['id']\n final ref = state.query['ref']; // query string ?ref=...\n final extra = state.arguments; // object passed via pushNamed(arguments:)\n return ProductDetailPage(id: id!);\n});\n```\n\n| Member | What it is |\n|---|---|\n| `state.uri` | the full resolved `Uri` (path + query) |\n| `state.params` | path params from the match, e.g. `{'id': '42'}` |\n| `state['id']` | shorthand for `state.params['id']` |\n| `state.query` | query parameters (`?a=1&b=2`) |\n| `state.arguments` | the object passed at navigation time (not in the URL) |\n\nYou can also read the current route from **outside** a route builder (e.g. a shell's\nactive‑tab highlight) with `context.routeState()`. It is **reactive** by default — the\nwidget rebuilds when the app navigates, so route‑aware chrome derives from the route\ninstead of mirroring it in local state. Pass `listen: false` to read it in a callback\nwithout subscribing.\n\n## Navigating\n\nThe navigation verbs are extension methods on `BuildContext`:\n\n```dart\ncontext.pushNamed('/products/42'); // stack a page on top\ncontext.navigate('/home'); // replace the whole stack (reset history)\ncontext.replace('/login'); // replace just the top route\ncontext.pop(result); // pop, delivering a result to the awaiting pushNamed\n```\n\n| Method | Effect |\n|---|---|\n| `pushNamed(path, {arguments})` | Pushes a page; returns a `Future` completed by the matching `pop(result)`. |\n| `navigate(path, {arguments})` | Replaces the whole stack and **resets history**. The v7 unification of 6.x's `navigate` + `pushNavigate`. |\n| `replace(path, {arguments})` | Replaces the **top** route (no back to the replaced one). |\n| `pop([result])` | Pops the top route, completing its `pushNamed` future with `result`. |\n| `maybePop([result])` | Pops only if there is something to pop; returns whether it did. |\n| `canPop()` | Whether `pop` would do anything. |\n| `popUntil(predicate)` | Pops until `predicate(RouteState)` holds. |\n| `popAndPushNamed(path, {result, arguments})` | Pops (delivering `result`), then pushes. |\n| `pushNamedAndRemoveUntil(path, predicate, {arguments})` | Pushes, then removes routes beneath it until `predicate` holds. |\n\n### The URL reflects the stack *base*\n\nThis is the v7 routing model: the URL mirrors the **base** of the navigation stack, not\nits top. So `navigate(...)` — which replaces the stack base — **changes the URL**, while\n`pushNamed(...)` stacks a page **modal‑like** that stays **out of** the URL. Use\n`navigate` for \"go here and own the address\" (tabs, top‑level destinations) and\n`pushNamed` for \"stack a detail/modal on top of where I am\".\n\n:::note\nInside a [`RouterOutlet`](./nested-routes.md), these verbs target the nearest outlet, so\nthe parent shell persists. Otherwise they target the root navigator.\n:::\n\n## Relative routes\n\nA path without a leading `/` is **relative to the route the context is on**, resolved\nlike a directory:\n\n```dart\n// On /home:\ncontext.pushNamed('dashboard'); // → /home/dashboard (bare or ./ = one level deeper)\ncontext.pushNamed('./dashboard'); // → /home/dashboard\ncontext.pushNamed('../settings'); // → /settings (.. climbs a level)\ncontext.pushNamed('/login'); // → /login (leading / = absolute)\n```\n\nThis improves on 6.x's raw `Uri.resolve`, which treated `/home` as a *file* and turned\n`dashboard` into `/dashboard` (dropping `home`). Modular treats the current location as\na directory, so a bare reference goes *inside* it. Query and fragment on the reference\nare preserved (`item?ref=x`).\n\n## Passing arguments and getting results\n\n`arguments` carries an arbitrary object to the target route, recovered through\n`RouteState.arguments`; the `Future` returned by `pushNamed` delivers a result back via\n`pop`:\n\n```dart\n// Caller — pass an object, await a result:\nfinal saved = await context.pushNamed(\n '/args/editor',\n arguments: EditorArgs(title: 'Draft'),\n);\n\n// Target route — read the object defensively:\nc.route('/args/editor', child: (ctx, state) {\n final args = state.arguments;\n if (args is! EditorArgs) {\n return const Scaffold(body: Center(child: Text('Open this from the Arguments page.')));\n }\n return EditorPage(args: args);\n});\n\n// Inside the editor — return a result:\ncontext.pop(true);\n```\n\n:::warning arguments are not in the URL\nUnlike `:id` path params, `arguments` is **not** part of the URL. A deep link or a web\nrefresh on `/args/editor` arrives with `arguments == null`, so always read it\ndefensively. Use path params or query for anything that must survive a refresh.\n:::\n\n## Guards\n\nA **guard** is a pure function `String? Function(RouteState)`: return a **redirect path**\nto send the user elsewhere, or `null` to allow navigation. Guards run **before** the\npage is shown.\n\n```dart\nc.route(\n '/settings/secret',\n guards: [\n // Reads DI at guard-eval time via inject(); a redirect is an absolute path.\n (state) => inject().unlocked ? null : '/home/settings',\n ],\n child: (ctx, state) => const SecretPage(),\n);\n```\n\nGuards are a list, evaluated in order — the first one that returns a path wins and\nredirects. Reach dependencies inside a guard with\n[`inject()`](./dependency-injection.md#resolving-with-injectt). A guard redirect is an\n**absolute** destination (there is no current context to be relative to). Use a guard to\ngate authenticated areas, or to redirect unknown paths to a 404 page.\n\n## Transitions\n\nSet a route's page transition with `transition:`:\n\n```dart\nc.route('/details', transition: TransitionType.fade, child: ...);\n```\n\n`TransitionType` is `material` (the default), `fade`, or `none` (instant).\n\n## Next\n\n- Persistent shells and tabs → [Nested routes & RouterOutlet](./nested-routes.md)\n- Page‑scoped view models → [State management](./state-management.md)", + markdown: "# Navigation\n\nModular's routing is built on Navigator 2.0 and matches paths like the web: static\nsegments, dynamic `:params`, query strings, nested routes and relative paths. You\nnavigate from any widget through extensions on `BuildContext`.\n\n## Declaring routes\n\nRoutes are declared on a module's context with `c.route`:\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route('/', child: (ctx, state) => const ProductListPage())\n ..route('/:id', child: (ctx, state) => ProductDetailPage(id: state['id']!));\n },\n);\n```\n\nThe full `route` signature:\n\n```dart\nc.route(\n String path, {\n required ModularWidgetBuilder child, // (BuildContext, RouteState) => Widget\n void Function(Scoped scoped)? provide, // page-scoped state — see State management\n void Function(ModularContext c)? children, // nested routes — see Nested routes\n List? guards, // redirects — see below\n PageTransition? transition, // preset, CustomTransition, or your own — see below\n});\n```\n\n## RouteState\n\nEvery route `child` receives a `RouteState` — an immutable snapshot of the current\nroute:\n\n```dart\nc.route('/product/:id', child: (ctx, state) {\n final id = state['id']; // path param, shorthand for state.params['id']\n final ref = state.query['ref']; // query string ?ref=...\n final extra = state.arguments; // object passed via pushNamed(arguments:)\n return ProductDetailPage(id: id!);\n});\n```\n\n| Member | What it is |\n|---|---|\n| `state.uri` | the full resolved `Uri` (path + query) |\n| `state.params` | path params from the match, e.g. `{'id': '42'}` |\n| `state['id']` | shorthand for `state.params['id']` |\n| `state.query` | query parameters (`?a=1&b=2`) |\n| `state.arguments` | the object passed at navigation time (not in the URL) |\n\nYou can also read the current route from **outside** a route builder (e.g. a shell's\nactive‑tab highlight) with `context.routeState()`. It is **reactive** by default — the\nwidget rebuilds when the app navigates, so route‑aware chrome derives from the route\ninstead of mirroring it in local state. Pass `listen: false` to read it in a callback\nwithout subscribing.\n\n## Navigating\n\nThe navigation verbs are extension methods on `BuildContext`:\n\n```dart\ncontext.pushNamed('/products/42'); // stack a page on top\ncontext.navigate('/home'); // replace the whole stack (reset history)\ncontext.replace('/login'); // replace just the top route\ncontext.pop(result); // pop, delivering a result to the awaiting pushNamed\n```\n\n| Method | Effect |\n|---|---|\n| `pushNamed(path, {arguments})` | Pushes a page; returns a `Future` completed by the matching `pop(result)`. |\n| `navigate(path, {arguments})` | Replaces the whole stack and **resets history**. The v7 unification of 6.x's `navigate` + `pushNavigate`. |\n| `replace(path, {arguments})` | Replaces the **top** route (no back to the replaced one). |\n| `pop([result])` | Pops the top route, completing its `pushNamed` future with `result`. |\n| `maybePop([result])` | Pops only if there is something to pop; returns whether it did. |\n| `canPop()` | Whether `pop` would do anything. |\n| `popUntil(predicate)` | Pops until `predicate(RouteState)` holds. |\n| `popAndPushNamed(path, {result, arguments})` | Pops (delivering `result`), then pushes. |\n| `pushNamedAndRemoveUntil(path, predicate, {arguments})` | Pushes, then removes routes beneath it until `predicate` holds. |\n\n### The URL reflects the stack *base*\n\nThis is the v7 routing model: the URL mirrors the **base** of the navigation stack, not\nits top. So `navigate(...)` — which replaces the stack base — **changes the URL**, while\n`pushNamed(...)` stacks a page **modal‑like** that stays **out of** the URL. Use\n`navigate` for \"go here and own the address\" (tabs, top‑level destinations) and\n`pushNamed` for \"stack a detail/modal on top of where I am\".\n\n:::note\nInside a [`RouterOutlet`](./nested-routes.md), these verbs target the nearest outlet, so\nthe parent shell persists. Otherwise they target the root navigator.\n:::\n\n## Relative routes\n\nA path without a leading `/` is **relative to the route the context is on**, resolved\nlike a directory:\n\n```dart\n// On /home:\ncontext.pushNamed('dashboard'); // → /home/dashboard (bare or ./ = one level deeper)\ncontext.pushNamed('./dashboard'); // → /home/dashboard\ncontext.pushNamed('../settings'); // → /settings (.. climbs a level)\ncontext.pushNamed('/login'); // → /login (leading / = absolute)\n```\n\nThis improves on 6.x's raw `Uri.resolve`, which treated `/home` as a *file* and turned\n`dashboard` into `/dashboard` (dropping `home`). Modular treats the current location as\na directory, so a bare reference goes *inside* it. Query and fragment on the reference\nare preserved (`item?ref=x`).\n\n## Passing arguments and getting results\n\n`arguments` carries an arbitrary object to the target route, recovered through\n`RouteState.arguments`; the `Future` returned by `pushNamed` delivers a result back via\n`pop`:\n\n```dart\n// Caller — pass an object, await a result:\nfinal saved = await context.pushNamed(\n '/args/editor',\n arguments: EditorArgs(title: 'Draft'),\n);\n\n// Target route — read the object defensively:\nc.route('/args/editor', child: (ctx, state) {\n final args = state.arguments;\n if (args is! EditorArgs) {\n return const Scaffold(body: Center(child: Text('Open this from the Arguments page.')));\n }\n return EditorPage(args: args);\n});\n\n// Inside the editor — return a result:\ncontext.pop(true);\n```\n\n:::warning arguments are not in the URL\nUnlike `:id` path params, `arguments` is **not** part of the URL. A deep link or a web\nrefresh on `/args/editor` arrives with `arguments == null`, so always read it\ndefensively. Use path params or query for anything that must survive a refresh.\n:::\n\n## Guards\n\nA **guard** is a pure function `String? Function(RouteState)`: return a **redirect path**\nto send the user elsewhere, or `null` to allow navigation. Guards run **before** the\npage is shown.\n\n```dart\nc.route(\n '/settings/secret',\n guards: [\n // Reads DI at guard-eval time via inject(); a redirect is an absolute path.\n (state) => inject().unlocked ? null : '/home/settings',\n ],\n child: (ctx, state) => const SecretPage(),\n);\n```\n\nGuards are a list, evaluated in order — the first one that returns a path wins and\nredirects. Reach dependencies inside a guard with\n[`inject()`](./dependency-injection.md#resolving-with-injectt). A guard redirect is an\n**absolute** destination (there is no current context to be relative to). Use a guard to\ngate authenticated areas, or to redirect unknown paths to a 404 page.\n\n## Transitions\n\nA route's page transition is any **`PageTransition`** — the contract that turns a\nroute into the `Page` pushed on the stack. There are three ways to supply one,\nfrom simplest to most powerful.\n\n### 1. Presets\n\n`TransitionType` ships three ready-made transitions, and each value **is** a\n`PageTransition`:\n\n```dart\nc.route('/details', transition: TransitionType.fade, child: ...);\n```\n\n`TransitionType` is `material`, `fade`, or `none` (instant).\n\n### 2. `CustomTransition` — bring your own animation\n\nWhen you just want a different animation, `CustomTransition` builds the `Page` for\nyou; you supply the `transitionsBuilder` (same signature as\n`PageRouteBuilder.transitionsBuilder`) and, optionally, `duration` and a few route\nflags (`reverseDuration`, `opaque`, `barrierColor`, `barrierDismissible`,\n`fullscreenDialog`):\n\n```dart\nc.route('/details/:id',\n transition: CustomTransition(\n duration: const Duration(milliseconds: 250),\n transitionsBuilder: (context, animation, secondary, child) => SlideTransition(\n position: animation.drive(\n Tween(begin: const Offset(1, 0), end: Offset.zero),\n ),\n child: child,\n ),\n ),\n child: (ctx, state) => DetailsPage(id: state['id']!));\n```\n\n### 3. Implement `PageTransition` — own the whole `Page`\n\nFor full control — a `CupertinoPage` with interactive swipe-back, a\n`fullscreenDialog`, a custom barrier, shared-axis from the `animations` package —\nimplement `PageTransition` and return whatever `Page` you like:\n\n```dart\nclass SharedAxisTransition extends PageTransition {\n const SharedAxisTransition();\n\n @override\n Page buildPage(LocalKey key, Widget child) =>\n // any Page you want — e.g. CupertinoPage, a custom PageRouteBuilder, …\n CupertinoPage(key: key, child: child);\n}\n\nc.route('/profile', transition: const SharedAxisTransition(), child: ...);\n```\n\n### An app-wide default\n\nSet a fallback transition once on `ModularApp.defaultTransition`; every route that\ndoesn't declare its own inherits it. Precedence is **route-local → app default →\n`material`**:\n\n```dart\nModularApp(\n module: appModule,\n defaultTransition: TransitionType.fade, // every route fades unless it overrides\n child: const AppRoot(),\n);\n```\n\nA route's own `transition:` always wins over the app default; leave it unset to\ninherit. With no `defaultTransition` set, the default is `TransitionType.material`.\n\n## Next\n\n- Persistent shells and tabs → [Nested routes & RouterOutlet](./nested-routes.md)\n- Page‑scoped view models → [State management](./state-management.md)", ), DocPage( path: "flutter_modular/nested-routes.md", @@ -38,7 +38,7 @@ const List docPages = [ DocPage( path: "flutter_modular/start.md", title: "Getting started", - markdown: "# Getting started\n\nThis page builds the smallest possible Modular app — a counter — and explains each\npiece. By the end you will have a module, an app bootstrap, and a page‑scoped view\nmodel wired to a route.\n\n## Install\n\nAdd **flutter_modular** to your project:\n\n```bash\nflutter pub add flutter_modular\n```\n\nThat yields a dependency on the v7 line:\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n flutter_modular: ^7.0.0\n```\n\n## A module is DI + Routes\n\nEverything starts with a **Module**: the object that declares a scope's dependency\ninjection and its routes. Build one functionally with `createModule`:\n\n```dart\nimport 'package:flutter_modular/flutter_modular.dart';\n\nfinal appModule = createModule(\n register: (c) {\n c.route(\n '/',\n provide: (s) => s.addChangeNotifier(CounterViewModel.new),\n child: (context, state) => const CounterPage(),\n );\n },\n);\n```\n\n- `c.route('/', child: ...)` declares the route shown at `/`. The builder receives the\n `BuildContext` and a [`RouteState`](./navigation.md#routestate) (path params, query,\n arguments).\n- `provide:` declares **page‑scoped state** for that route — here a `CounterViewModel`\n built when the page mounts and `dispose()`d when it leaves. More on that in\n [State management](./state-management.md).\n\n:::tip Store modules in a `final`\nModules are deduplicated **by identity**. Keep each one in a top‑level `final` and\nreference that same value everywhere it is composed — never `createModule(...)` twice\nfor the same logical module.\n:::\n\n## Bootstrap with ModularApp\n\n`ModularApp` is the **first widget** of your app, sitting *above* `MaterialApp`. It\nbootstraps the module once (collecting its routes + DI), owns the resulting injector,\nand builds the router config:\n\n```dart title=\"lib/main.dart\"\nimport 'package:flutter/material.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\nvoid main() {\n runApp(\n ModularApp(\n module: appModule,\n child: const AppRoot(),\n ),\n );\n}\n```\n\nThe `child` is your `MaterialApp.router`. It reads the router config that `ModularApp`\nbuilt with `ModularApp.routerConfigOf(context)`:\n\n```dart\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MaterialApp.router(\n title: 'My Smart App',\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n```\n\n:::warning\n`ModularApp` must be **above** the `MaterialApp.router` that reads\n`routerConfigOf(context)`. That position is also what makes *app‑scoped* state\n(theme, locale, session) possible — see [State management](./state-management.md#app-scoped-state).\n:::\n\n## The full counter\n\nPutting it together — a complete, runnable app (this is the package's own\n[`example`](https://github.com/Flutterando/modular/tree/master/example) in miniature):\n\n```dart title=\"lib/main.dart\"\nimport 'package:flutter/material.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\n/// A page-scoped view model (built per page mount, disposed on exit).\nclass CounterViewModel extends ChangeNotifier {\n int count = 0;\n\n void increment() {\n count++;\n notifyListeners();\n }\n}\n\nfinal appModule = createModule(\n register: (c) {\n c.route(\n '/',\n provide: (s) => s.addChangeNotifier(CounterViewModel.new),\n child: (context, state) => const CounterPage(),\n );\n },\n);\n\nvoid main() {\n runApp(ModularApp(module: appModule, child: const AppRoot()));\n}\n\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MaterialApp.router(\n title: 'flutter_modular counter',\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n\nclass CounterPage extends StatelessWidget {\n const CounterPage({super.key});\n\n @override\n Widget build(BuildContext context) {\n final vm = context.watch(); // reactive, page-scoped\n return Scaffold(\n appBar: AppBar(title: const Text('Counter')),\n body: Center(child: Text('count: \${vm.count}')),\n floatingActionButton: FloatingActionButton(\n onPressed: context.read().increment,\n child: const Icon(Icons.add),\n ),\n );\n }\n}\n```\n\n## Configuring the root\n\n`ModularApp` accepts a few options for the root navigator and the entry route:\n\n```dart\nModularApp(\n module: appModule,\n initialRoute: '/home', // first route when the platform reports no deep link\n navigatorKey: myNavigatorKey, // imperative access from outside the tree\n navigatorObservers: [myObserver], // analytics, RouteObserver, …\n child: const AppRoot(),\n);\n```\n\n- **`initialRoute`** is shown when the platform hands you the bare `/` (app cold‑start\n with no deep link). A real entry URL — a web refresh on `/products/3`, an app link —\n overrides it.\n- **`navigatorKey`** lets you reach the root `Navigator` imperatively (e.g. to show a\n global dialog). A fresh key is created if you omit it.\n- **`navigatorObservers`** are attached to the root navigator.\n\n:::tip Clean URLs on the web\nCall `usePathUrlStrategy()` (from `package:flutter_web_plugins/url_strategy.dart`) at\nthe top of `main()` to get `/products/3` instead of `/#/products/3`. It is a no‑op off\nthe web.\n:::\n\nThat is enough to run a Modular app. Next, learn how to grow it into multiple features\nwith [Modules](./module.md), or jump to [Navigation](./navigation.md).", + markdown: "# Getting started\n\nThis page builds the smallest possible Modular app — a counter — and explains each\npiece. By the end you will have a module, an app bootstrap, and a page‑scoped view\nmodel wired to a route.\n\n## Install\n\nAdd **flutter_modular** to your project:\n\n```bash\nflutter pub add flutter_modular\n```\n\nThat yields a dependency on the v7 line:\n\n```yaml title=\"pubspec.yaml\"\ndependencies:\n flutter_modular: ^7.0.0\n```\n\n## A module is DI + Routes\n\nEverything starts with a **Module**: the object that declares a scope's dependency\ninjection and its routes. Build one functionally with `createModule`:\n\n```dart\nimport 'package:flutter_modular/flutter_modular.dart';\n\nfinal appModule = createModule(\n register: (c) {\n c.route(\n '/',\n provide: (s) => s.addChangeNotifier(CounterViewModel.new),\n child: (context, state) => const CounterPage(),\n );\n },\n);\n```\n\n- `c.route('/', child: ...)` declares the route shown at `/`. The builder receives the\n `BuildContext` and a [`RouteState`](./navigation.md#routestate) (path params, query,\n arguments).\n- `provide:` declares **page‑scoped state** for that route — here a `CounterViewModel`\n built when the page mounts and `dispose()`d when it leaves. More on that in\n [State management](./state-management.md).\n\n:::tip Store modules in a `final`\nModules are deduplicated **by identity**. Keep each one in a top‑level `final` and\nreference that same value everywhere it is composed — never `createModule(...)` twice\nfor the same logical module.\n:::\n\n## Bootstrap with ModularApp\n\n`ModularApp` is the **first widget** of your app, sitting *above* `MaterialApp`. It\nbootstraps the module once (collecting its routes + DI), owns the resulting injector,\nand builds the router config:\n\n```dart title=\"lib/main.dart\"\nimport 'package:flutter/material.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\nvoid main() {\n runApp(\n ModularApp(\n module: appModule,\n child: const AppRoot(),\n ),\n );\n}\n```\n\nThe `child` is your `MaterialApp.router`. It reads the router config that `ModularApp`\nbuilt with `ModularApp.routerConfigOf(context)`:\n\n```dart\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MaterialApp.router(\n title: 'My Smart App',\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n```\n\n:::warning\n`ModularApp` must be **above** the `MaterialApp.router` that reads\n`routerConfigOf(context)`. That position is also what makes *app‑scoped* state\n(theme, locale, session) possible — see [State management](./state-management.md#app-scoped-state).\n:::\n\n## The full counter\n\nPutting it together — a complete, runnable app (this is the package's own\n[`example`](https://github.com/Flutterando/modular/tree/master/example) in miniature):\n\n```dart title=\"lib/main.dart\"\nimport 'package:flutter/material.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\n/// A page-scoped view model (built per page mount, disposed on exit).\nclass CounterViewModel extends ChangeNotifier {\n int count = 0;\n\n void increment() {\n count++;\n notifyListeners();\n }\n}\n\nfinal appModule = createModule(\n register: (c) {\n c.route(\n '/',\n provide: (s) => s.addChangeNotifier(CounterViewModel.new),\n child: (context, state) => const CounterPage(),\n );\n },\n);\n\nvoid main() {\n runApp(ModularApp(module: appModule, child: const AppRoot()));\n}\n\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n return MaterialApp.router(\n title: 'flutter_modular counter',\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n\nclass CounterPage extends StatelessWidget {\n const CounterPage({super.key});\n\n @override\n Widget build(BuildContext context) {\n final vm = context.watch(); // reactive, page-scoped\n return Scaffold(\n appBar: AppBar(title: const Text('Counter')),\n body: Center(child: Text('count: \${vm.count}')),\n floatingActionButton: FloatingActionButton(\n onPressed: context.read().increment,\n child: const Icon(Icons.add),\n ),\n );\n }\n}\n```\n\n## Configuring the root\n\n`ModularApp` accepts a few options for the root navigator and the entry route:\n\n```dart\nModularApp(\n module: appModule,\n initialRoute: '/home', // first route when the platform reports no deep link\n navigatorKey: myNavigatorKey, // imperative access from outside the tree\n navigatorObservers: [myObserver], // analytics, RouteObserver, …\n defaultTransition: TransitionType.fade, // app-wide fallback page transition\n child: const AppRoot(),\n);\n```\n\n- **`initialRoute`** is shown when the platform hands you the bare `/` (app cold‑start\n with no deep link). A real entry URL — a web refresh on `/products/3`, an app link —\n overrides it.\n- **`navigatorKey`** lets you reach the root `Navigator` imperatively (e.g. to show a\n global dialog). A fresh key is created if you omit it.\n- **`navigatorObservers`** are attached to the root navigator.\n- **`defaultTransition`** is the page transition every route inherits unless it sets its\n own `transition:`. Defaults to `TransitionType.material`. See\n [Transitions](./navigation.md#transitions).\n\n:::tip Clean URLs on the web\nCall `usePathUrlStrategy()` (from `package:flutter_web_plugins/url_strategy.dart`) at\nthe top of `main()` to get `/products/3` instead of `/#/products/3`. It is a no‑op off\nthe web.\n:::\n\nThat is enough to run a Modular app. Next, learn how to grow it into multiple features\nwith [Modules](./module.md), or jump to [Navigation](./navigation.md).", ), DocPage( path: "flutter_modular/state-management.md", @@ -259,7 +259,7 @@ const List docChunks = [ pageTitle: "Navigation", heading: "Declaring routes", anchor: "declaring-routes", - text: "## Declaring routes\n\nRoutes are declared on a module's context with `c.route`:\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route('/', child: (ctx, state) => const ProductListPage())\n ..route('/:id', child: (ctx, state) => ProductDetailPage(id: state['id']!));\n },\n);\n```\n\nThe full `route` signature:\n\n```dart\nc.route(\n String path, {\n required ModularWidgetBuilder child, // (BuildContext, RouteState) => Widget\n void Function(Scoped scoped)? provide, // page-scoped state — see State management\n void Function(ModularContext c)? children, // nested routes — see Nested routes\n List? guards, // redirects — see below\n TransitionType transition, // material | fade | none\n});\n```", + text: "## Declaring routes\n\nRoutes are declared on a module's context with `c.route`:\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route('/', child: (ctx, state) => const ProductListPage())\n ..route('/:id', child: (ctx, state) => ProductDetailPage(id: state['id']!));\n },\n);\n```\n\nThe full `route` signature:\n\n```dart\nc.route(\n String path, {\n required ModularWidgetBuilder child, // (BuildContext, RouteState) => Widget\n void Function(Scoped scoped)? provide, // page-scoped state — see State management\n void Function(ModularContext c)? children, // nested routes — see Nested routes\n List? guards, // redirects — see below\n PageTransition? transition, // preset, CustomTransition, or your own — see below\n});\n```", ), DocChunk( pagePath: "flutter_modular/navigation.md", @@ -308,7 +308,35 @@ const List docChunks = [ pageTitle: "Navigation", heading: "Transitions", anchor: "transitions", - text: "## Transitions\n\nSet a route's page transition with `transition:`:\n\n```dart\nc.route('/details', transition: TransitionType.fade, child: ...);\n```\n\n`TransitionType` is `material` (the default), `fade`, or `none` (instant).", + text: "## Transitions\n\nA route's page transition is any **`PageTransition`** — the contract that turns a\nroute into the `Page` pushed on the stack. There are three ways to supply one,\nfrom simplest to most powerful.", + ), + DocChunk( + pagePath: "flutter_modular/navigation.md", + pageTitle: "Navigation", + heading: "1. Presets", + anchor: "1-presets", + text: "### 1. Presets\n\n`TransitionType` ships three ready-made transitions, and each value **is** a\n`PageTransition`:\n\n```dart\nc.route('/details', transition: TransitionType.fade, child: ...);\n```\n\n`TransitionType` is `material`, `fade`, or `none` (instant).", + ), + DocChunk( + pagePath: "flutter_modular/navigation.md", + pageTitle: "Navigation", + heading: "2. `CustomTransition` — bring your own animation", + anchor: "2-customtransition-bring-your-own-animation", + text: "### 2. `CustomTransition` — bring your own animation\n\nWhen you just want a different animation, `CustomTransition` builds the `Page` for\nyou; you supply the `transitionsBuilder` (same signature as\n`PageRouteBuilder.transitionsBuilder`) and, optionally, `duration` and a few route\nflags (`reverseDuration`, `opaque`, `barrierColor`, `barrierDismissible`,\n`fullscreenDialog`):\n\n```dart\nc.route('/details/:id',\n transition: CustomTransition(\n duration: const Duration(milliseconds: 250),\n transitionsBuilder: (context, animation, secondary, child) => SlideTransition(\n position: animation.drive(\n Tween(begin: const Offset(1, 0), end: Offset.zero),\n ),\n child: child,\n ),\n ),\n child: (ctx, state) => DetailsPage(id: state['id']!));\n```", + ), + DocChunk( + pagePath: "flutter_modular/navigation.md", + pageTitle: "Navigation", + heading: "3. Implement `PageTransition` — own the whole `Page`", + anchor: "3-implement-pagetransition-own-the-whole-page", + text: "### 3. Implement `PageTransition` — own the whole `Page`\n\nFor full control — a `CupertinoPage` with interactive swipe-back, a\n`fullscreenDialog`, a custom barrier, shared-axis from the `animations` package —\nimplement `PageTransition` and return whatever `Page` you like:\n\n```dart\nclass SharedAxisTransition extends PageTransition {\n const SharedAxisTransition();\n\n @override\n Page buildPage(LocalKey key, Widget child) =>\n // any Page you want — e.g. CupertinoPage, a custom PageRouteBuilder, …\n CupertinoPage(key: key, child: child);\n}\n\nc.route('/profile', transition: const SharedAxisTransition(), child: ...);\n```", + ), + DocChunk( + pagePath: "flutter_modular/navigation.md", + pageTitle: "Navigation", + heading: "An app-wide default", + anchor: "an-app-wide-default", + text: "### An app-wide default\n\nSet a fallback transition once on `ModularApp.defaultTransition`; every route that\ndoesn't declare its own inherits it. Precedence is **route-local → app default →\n`material`**:\n\n```dart\nModularApp(\n module: appModule,\n defaultTransition: TransitionType.fade, // every route fades unless it overrides\n child: const AppRoot(),\n);\n```\n\nA route's own `transition:` always wins over the app default; leave it unset to\ninherit. With no `defaultTransition` set, the default is `TransitionType.material`.", ), DocChunk( pagePath: "flutter_modular/navigation.md", @@ -399,7 +427,7 @@ const List docChunks = [ pageTitle: "Getting started", heading: "Configuring the root", anchor: "configuring-the-root", - text: "## Configuring the root\n\n`ModularApp` accepts a few options for the root navigator and the entry route:\n\n```dart\nModularApp(\n module: appModule,\n initialRoute: '/home', // first route when the platform reports no deep link\n navigatorKey: myNavigatorKey, // imperative access from outside the tree\n navigatorObservers: [myObserver], // analytics, RouteObserver, …\n child: const AppRoot(),\n);\n```\n\n- **`initialRoute`** is shown when the platform hands you the bare `/` (app cold‑start\n with no deep link). A real entry URL — a web refresh on `/products/3`, an app link —\n overrides it.\n- **`navigatorKey`** lets you reach the root `Navigator` imperatively (e.g. to show a\n global dialog). A fresh key is created if you omit it.\n- **`navigatorObservers`** are attached to the root navigator.\n\n:::tip Clean URLs on the web\nCall `usePathUrlStrategy()` (from `package:flutter_web_plugins/url_strategy.dart`) at\nthe top of `main()` to get `/products/3` instead of `/#/products/3`. It is a no‑op off\nthe web.\n:::\n\nThat is enough to run a Modular app. Next, learn how to grow it into multiple features\nwith [Modules](./module.md), or jump to [Navigation](./navigation.md).", + text: "## Configuring the root\n\n`ModularApp` accepts a few options for the root navigator and the entry route:\n\n```dart\nModularApp(\n module: appModule,\n initialRoute: '/home', // first route when the platform reports no deep link\n navigatorKey: myNavigatorKey, // imperative access from outside the tree\n navigatorObservers: [myObserver], // analytics, RouteObserver, …\n defaultTransition: TransitionType.fade, // app-wide fallback page transition\n child: const AppRoot(),\n);\n```\n\n- **`initialRoute`** is shown when the platform hands you the bare `/` (app cold‑start\n with no deep link). A real entry URL — a web refresh on `/products/3`, an app link —\n overrides it.\n- **`navigatorKey`** lets you reach the root `Navigator` imperatively (e.g. to show a\n global dialog). A fresh key is created if you omit it.\n- **`navigatorObservers`** are attached to the root navigator.\n- **`defaultTransition`** is the page transition every route inherits unless it sets its\n own `transition:`. Defaults to `TransitionType.material`. See\n [Transitions](./navigation.md#transitions).\n\n:::tip Clean URLs on the web\nCall `usePathUrlStrategy()` (from `package:flutter_web_plugins/url_strategy.dart`) at\nthe top of `main()` to get `/products/3` instead of `/#/products/3`. It is a no‑op off\nthe web.\n:::\n\nThat is enough to run a Modular app. Next, learn how to grow it into multiple features\nwith [Modules](./module.md), or jump to [Navigation](./navigation.md).", ), DocChunk( pagePath: "flutter_modular/state-management.md", diff --git a/tool/docs_mcp/lib/src/server.dart b/tool/docs_mcp/lib/src/server.dart index deac6584..e4a31474 100644 --- a/tool/docs_mcp/lib/src/server.dart +++ b/tool/docs_mcp/lib/src/server.dart @@ -14,7 +14,7 @@ import 'generated/docs_data.g.dart'; import 'search_index.dart'; /// Version reported to clients in the MCP `initialize` handshake. -const String serverVersion = '0.2.2'; +const String serverVersion = '0.2.3'; /// URI scheme/prefix under which each doc page is exposed as a resource. const String _uriPrefix = 'modular-docs:///'; diff --git a/tool/docs_mcp/pubspec.yaml b/tool/docs_mcp/pubspec.yaml index 52d48e40..1d5283f5 100644 --- a/tool/docs_mcp/pubspec.yaml +++ b/tool/docs_mcp/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_modular_docs_mcp description: >- MCP server that serves the flutter_modular documentation to AI coding clients as searchable resources plus a keyword search tool. -version: 0.2.2 +version: 0.2.3 repository: https://github.com/Flutterando/modular homepage: https://modular.flutterando.com.br topics: