From b37b5e4435cf9b1a3d0c45694cd2b2a8380fa677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Mart=C3=ADnez=20Catal=C3=A1?= <3d24rd0@gmail.com> Date: Wed, 23 Sep 2026 13:59:59 +0200 Subject: [PATCH 1/2] feat(transitions): v0.3.0 modern transitions, accessibility, pure widgets example - Add Material 3 motion transitions (fade_through, fade_scale, shared_axis_x/y/z, scale) - Add authentic iOS 16-18 Cupertino transition (slide_cupertino with 1/3 parallax & edge shadow) - Add CustomBuildTransition support across BrowserRoute and TraceRoute - Add platform-adaptive transition default (Browser.defaultAdaptiveTransition) - Add zero-config reduced motion / WCAG accessibility bypass - Add RepaintBoundary rendering isolation to route transitions - Fix Banner hit-testing pass-through and OverlayModal secondary animation - Refactor example to 100% pure widgets.dart with interactive duration: Duration.zero banner - Add comprehensive unit & widget tests (99 passing tests, >95% coverage) --- CHANGELOG.md | 11 + README.md | 81 +++- example/lib/app_traces.dart | 56 ++- example/lib/home_screen.dart | 360 ++++++++++++--- example/lib/push_args.dart | 2 +- lib/browser.dart | 18 + lib/browser_route.dart | 7 + lib/modal_route/browser_page_route.dart | 37 +- lib/modal_route/browser_popup_route.dart | 29 +- .../params/overlay_trace_route.dart | 1 + lib/modal_route/params/page_trace_route.dart | 1 + lib/modal_route/params/popup_trace_route.dart | 1 + lib/modal_route/params/swipe_trace_route.dart | 1 + lib/modal_route/params/trace_route.dart | 10 + .../custom_build_transition.dart | 35 ++ .../fade_scale_build_transition.dart | 21 + .../fade_through_build_transition.dart | 24 + .../scale_build_transition.dart | 24 + .../shared_axis_build_transition.dart | 26 ++ .../slide_cupertino_build_transition.dart | 24 + .../build_transitions/widgets/fade_scale.dart | 65 +++ .../widgets/fade_through.dart | 63 +++ .../build_transitions/widgets/scale.dart | 53 +++ .../widgets/shared_axis.dart | 157 +++++++ .../widgets/slide_cupertino.dart | 76 ++++ .../transitions/route_transition.dart | 37 ++ lib/overlay/banner.dart | 28 +- lib/overlay/overlay_manager.dart | 3 +- lib/overlay/overlay_modal.dart | 4 +- pubspec.yaml | 2 +- ...rn_route_transitions_integration_test.dart | 423 ++++++++++++++++++ test/modern_transitions_test.dart | 371 +++++++++++++++ test/overlay_manager_test.dart | 119 +++++ test/sheet_test.dart | 44 ++ 34 files changed, 2086 insertions(+), 128 deletions(-) create mode 100644 lib/modal_route/transitions/build_transitions/custom_build_transition.dart create mode 100644 lib/modal_route/transitions/build_transitions/fade_scale_build_transition.dart create mode 100644 lib/modal_route/transitions/build_transitions/fade_through_build_transition.dart create mode 100644 lib/modal_route/transitions/build_transitions/scale_build_transition.dart create mode 100644 lib/modal_route/transitions/build_transitions/shared_axis_build_transition.dart create mode 100644 lib/modal_route/transitions/build_transitions/slide_cupertino_build_transition.dart create mode 100644 lib/modal_route/transitions/build_transitions/widgets/fade_scale.dart create mode 100644 lib/modal_route/transitions/build_transitions/widgets/fade_through.dart create mode 100644 lib/modal_route/transitions/build_transitions/widgets/scale.dart create mode 100644 lib/modal_route/transitions/build_transitions/widgets/shared_axis.dart create mode 100644 lib/modal_route/transitions/build_transitions/widgets/slide_cupertino.dart create mode 100644 test/modern_route_transitions_integration_test.dart create mode 100644 test/modern_transitions_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a9f0bd..57d444c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.3.0 + +* **Feat (Transitions & Motion)**: Implemented modern **Material 3 Transitions** (`fade_through`, `fade_scale`, `shared_axis_x`, `shared_axis_y`, `shared_axis_z`, `scale`) and authentic **Cupertino iOS 16–18** transition (`slide_cupertino` with flat edges, 1/3 horizontal parallax, and left-edge drop shadow). +* **Feat (Custom Transitions)**: Added `CustomBuildTransition` and `customTransition` parameter to `BrowserRoute`, `TraceRoute`, `PageTraceRoute`, `PopupTraceRoute`, `SwipeTraceRoute`, and `OverlayTraceRoute`. +* **Feat (Platform Adaptivity)**: Added `Browser.defaultAdaptiveTransition` providing platform-native motion defaults (Cupertino on iOS/macOS, Shared Axis X on Android/Fuchsia, Fade Scale on Web/Windows/Linux). +* **Feat (Accessibility & WCAG)**: Implemented zero-configuration reduced motion support detecting `MediaQuery.disableAnimationsOf(context)` and `MediaQuery.accessibleNavigationOf(context)` to bypass animations immediately. +* **Perf**: Added `RepaintBoundary` rendering isolation in route transitions to avoid rebuilding ancestor tree rendering layers. +* **Fix (Overlay & Banners)**: Fixed secondary animation in `OverlayModal` to return `kAlwaysDismissedAnimation` (preventing visual offset / hit-test misalignment) and eliminated full-screen root opaque gesture detector in `Banner`, enabling seamless pass-through scrolling, clicks, and interactive banner buttons. +* **Test**: Expanded test coverage to 95.4% (99 passing unit & widget tests) covering modern transitions, accessibility, custom transitions, `Browser`, `BrowserRoute`, `NavigatorX` extensions, `TraceRoute`, modal barriers, swipe physics/gestures, and overlay lifecycle. +* **Docs & Example**: Enhanced `README.md` with complete transitions catalog, custom transitions guide, accessibility guide, and platform adaptive configuration. Converted `example/` into a 100% pure `package:flutter/widgets.dart` showcase with zero Material or Cupertino dependencies. + ## 0.2.0 * **Feat**: Modernized codebase to Dart 3.13 syntax, leveraging concise constructor declarations (`new(...)`), dot shorthands, and cleaner expression bodies. diff --git a/README.md b/README.md index c61fcf5..c49b596 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Add `browser_router` to your `pubspec.yaml`: ```yaml dependencies: - browser_router: ^0.2.0 + browser_router: ^0.3.0 ``` Then run: @@ -313,28 +313,81 @@ Change how a route is presented without altering its widget implementation: - **`SwipeTraceRoute`**: Displays the route as an interactive bottom sheet with swipe-to-dismiss gestures. - **`OverlayTraceRoute`**: Displays the route in the overlay layer. -### Available Transitions +### Available Transitions (`RouteTransition`) -- `RouteTransition.slide_right` -- `RouteTransition.slide_left` -- `RouteTransition.slide_up` -- `RouteTransition.slide_down` -- `RouteTransition.fade` -- `RouteTransition.scale` -- `RouteTransition.none` +`browser_router` includes modern Material 3, iOS Cupertino, Web, and legacy presets: + +| Preset | Platform / Style | Motion Behavior | +| :--- | :--- | :--- | +| `RouteTransition.fade_through` | **Material 3 & Web** | Outgoing fades out & scales (1.0 -> 0.96), incoming fades in & scales (0.92 -> 1.0). Ideal for bottom nav bars and top-level destinations. | +| `RouteTransition.fade_scale` | **Modern Web & M3** | Snappy zoom-fade (0.95 -> 1.0) with fast easing. Perfect for web SPAs, search overlays, and dialogs. | +| `RouteTransition.shared_axis_x` | **Material 3** | Horizontal directional slide with subtle fade. Ideal for wizards and linear multi-step flows. | +| `RouteTransition.shared_axis_y` | **Material 3** | Vertical directional slide with subtle fade. Ideal for form expansions and vertical progressions. | +| `RouteTransition.shared_axis_z` | **Material 3** | Depth zoom (0.8 -> 1.0 / 1.0 -> 1.1) with fade. Ideal for drill-down hierarchies. | +| `RouteTransition.slide_cupertino`| **Authentic iOS** | iOS native push with left-edge gradient drop shadow and 1/3 parallax on the exiting route. | +| `RouteTransition.scale` | **Popup / Dialog** | Clean scale and fade animation for alerts and confirmation modals. | +| `RouteTransition.slide_right` | **Legacy Slide** | Standard horizontal right slide. | +| `RouteTransition.slide_left` | **Legacy Slide** | Standard horizontal left slide. | +| `RouteTransition.slide_up` | **Legacy Slide** | Standard vertical upward slide. | +| `RouteTransition.slide_down` | **Legacy Slide** | Standard vertical downward slide. | +| `RouteTransition.fade` | **Legacy Fade** | Simple fade in / fade out. | +| `RouteTransition.none` | **Instant** | Zero-duration transition without animation. | + +--- + +### Custom Transitions (`CustomBuildTransition`) + +You can define custom transition builders at the route level or per navigation request: + +```dart +// At BrowserRoute level +BrowserRoute( + path: '/custom', + page: const CustomScreen(), + customTransition: CustomBuildTransition( + ({required animation, required secondaryAnimation, required child}) { + return RotationTransition( + turns: animation, + child: child, + ); + }, + ), +); + +// Or per navigation request via TraceRoute +context.pushNamed( + '/custom', + traceRoute: PageTraceRoute( + customTransition: CustomBuildTransition( + ({required animation, required secondaryAnimation, required child}) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ), +); +``` + +--- + +### Accessibility & Reduced Motion (WCAG Compliance) + +All `BrowserPageRoute` and `BrowserPopupRoute` transitions automatically detect system accessibility settings: +- `MediaQuery.disableAnimationsOf(context)` +- `MediaQuery.accessibleNavigationOf(context)` + +When motion reduction is requested by the user, transitions bypass animations instantly with zero motion discomfort, requiring zero extra configuration. + +--- ### Adaptive Transitions and Traces -Configure global transition or presentation rules in `Browser`: +Configure global platform-adaptive transition rules using built-in `Browser.defaultAdaptiveTransition`: ```dart Browser( routes: routes, defaultRoute: routes.first, - adaptiveTransition: (route) { - // Apply platform-specific transitions - return RouteTransition.slide_right; - }, + adaptiveTransition: Browser.defaultAdaptiveTransition, // iOS: slide_cupertino, Android: shared_axis_x, Web/Desktop: fade_scale adaptiveTrace: (name) { // All routes under /modal/ open as popups automatically if (name?.startsWith('/modal/') ?? false) { diff --git a/example/lib/app_traces.dart b/example/lib/app_traces.dart index 2615b20..3e885e7 100644 --- a/example/lib/app_traces.dart +++ b/example/lib/app_traces.dart @@ -1,6 +1,6 @@ +import 'package:browser_example/push_args.dart'; import 'package:browser_router/browser.dart'; - -import 'push_args.dart'; +import 'package:flutter/widgets.dart'; /// A centralized, type-safe way to manage all application paths. /// This avoids using raw strings for routes throughout the app. @@ -46,21 +46,19 @@ class AppTrace extends Trace { /// Navigates to the screen that demonstrates returning arguments via pop. factory toPopArgs() => AppTrace._(path: AppPath.popArgs.path); - factory toSetPopArgs() => - AppTrace._(path: AppPath.toSetPopArgs.path); + factory toSetPopArgs() => AppTrace._(path: AppPath.toSetPopArgs.path); /// Navigates to the intermediate screen for the multi-level pop example. - factory toIntermediate() => - AppTrace._(path: AppPath.intermediate.path); + factory toIntermediate() => AppTrace._(path: AppPath.intermediate.path); /// Navigates to the deepest screen for the multi-level pop example. factory toDeep() => AppTrace._(path: AppPath.deep.path); - /// Presents a specific screen as a modal popup. - factory asPopup() { + /// Presents a specific screen as a modal popup with modern scale transition. + factory asPopup({RouteTransition transition = RouteTransition.scale}) { return AppTrace._( path: AppPath.popupContent.path, - traceRoute: const PopupTraceRoute(routeTransition: RouteTransition.fade), + traceRoute: PopupTraceRoute(routeTransition: transition), ); } @@ -68,12 +66,48 @@ class AppTrace extends Trace { factory asSheet() { return AppTrace._( path: AppPath.pushArgs.path, - args: - PushArgs(message: 'Presented as a Sheet', source: 'SwipeTraceRoute'), + args: const PushArgs( + message: 'Presented as a Sheet', + source: 'SwipeTraceRoute', + ), traceRoute: const SwipeTraceRoute( screenMaximumPercentage: 0.6, routeTransition: RouteTransition.none, ), ); } + + /// Navigates using a custom builder transition (e.g. rotation & scale). + factory toCustomTransition({required String name}) { + return AppTrace._( + path: AppPath.pushArgs.path, + args: PushArgs( + message: 'Custom transition: $name', + source: 'CustomBuildTransition', + ), + traceRoute: PageTraceRoute( + customTransition: CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutBack, + ); + return ScaleTransition( + scale: curvedAnimation, + child: RotationTransition( + turns: Tween(begin: 0.85, end: 1).animate( + curvedAnimation, + ), + child: child, + ), + ); + }, + ), + ), + ); + } } diff --git a/example/lib/home_screen.dart b/example/lib/home_screen.dart index 054521d..c74f80b 100644 --- a/example/lib/home_screen.dart +++ b/example/lib/home_screen.dart @@ -2,7 +2,7 @@ import 'package:browser_example/app_traces.dart'; import 'package:browser_example/pop_result_args.dart'; import 'package:browser_example/push_args.dart'; import 'package:browser_router/browser.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; class HomeScreen extends StatefulWidget { const new({super.key}); @@ -18,9 +18,7 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { return Browser.watch( onAppear: (context, deepLink) { - // Defer the state update until after the build phase is complete. - - if (!mounted) return; // Ensure the widget is still in the tree. + if (!mounted) return; // First, handle pop arguments, as they are a one-time event. final popArgs = context.getArgumentAndClean(); @@ -30,10 +28,10 @@ class _HomeScreenState extends State { _eventMessage = '[From Pop]: ${popArgs.result}'; }); }); - return; // Process one event at a time + return; } - // Second, handle the deep link parameter from the callback. + // Second, handle deep link parameter from the callback. if (deepLink != null) { WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { @@ -50,16 +48,22 @@ class _HomeScreenState extends State { children: [ const Text( 'Browser Examples', - style: TextStyle(color: Color(0xFFFFFFFF), fontSize: 24), + style: TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 24, + fontWeight: FontWeight.bold, + ), textDirection: TextDirection.ltr, ), if (_eventMessage != null) Padding( - padding: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.only(top: 12), child: Text( 'Last Event: "$_eventMessage"', - style: - const TextStyle(color: Color(0xFFFFFFFF), fontSize: 16), + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 15, + ), textDirection: TextDirection.ltr, ), ), @@ -67,10 +71,176 @@ class _HomeScreenState extends State { Expanded( child: ListView( children: [ + // --- Material 3 Motion --- + const _SectionTitle(title: '✨ Material 3 Motion Transitions'), + _ExampleButton( + text: 'Fade Through (Top-Level Tabs / Bar)', + onTap: () => AppTrace.toPushArgs( + message: 'Material 3 Fade Through (scale 0.92->1.0 & fade intervals)', + source: 'M3 Fade Through', + transition: RouteTransition.fade_through, + ).push(context), + ), + _ExampleButton( + text: 'Shared Axis X (Horizontal / Wizards)', + onTap: () => AppTrace.toPushArgs( + message: 'Material 3 Shared Axis X (directional horizontal slide & fade)', + source: 'M3 Shared Axis X', + transition: RouteTransition.shared_axis_x, + ).push(context), + ), + _ExampleButton( + text: 'Shared Axis Y (Vertical / Expansion)', + onTap: () => AppTrace.toPushArgs( + message: 'Material 3 Shared Axis Y (directional vertical slide & fade)', + source: 'M3 Shared Axis Y', + transition: RouteTransition.shared_axis_y, + ).push(context), + ), + _ExampleButton( + text: 'Shared Axis Z (Depth / Drill-Down)', + onTap: () => AppTrace.toPushArgs( + message: 'Material 3 Shared Axis Z (scale 0.8->1.0 / 1.0->1.1 depth)', + source: 'M3 Shared Axis Z', + transition: RouteTransition.shared_axis_z, + ).push(context), + ), + _ExampleButton( + text: 'Scale (Popup / Modal Dialog)', + onTap: () => AppTrace.toPushArgs( + message: 'Material 3 / Clean Scale & Fade transition', + source: 'M3 Scale', + transition: RouteTransition.scale, + ).push(context), + ), + const SizedBox(height: 20), + + // --- Apple Cupertino iOS Motion --- + const _SectionTitle(title: '🍎 iOS 16–18 Cupertino Native Motion'), + _ExampleButton( + text: 'Slide Cupertino (Shadow + 1/3 Parallax)', + onTap: () => AppTrace.toPushArgs( + message: 'Authentic iOS Cupertino Slide with flat drop-shadow & 1/3 horizontal parallax', + source: 'Cupertino Motion', + transition: RouteTransition.slide_cupertino, + ).push(context), + ), + const SizedBox(height: 20), + + // --- Web Micro-Transitions --- + const _SectionTitle(title: '🌐 Web Micro-Transitions (SPA)'), + _ExampleButton( + text: 'Fade Scale (Fast Zoom-Fade 0.95->1.0)', + onTap: () => AppTrace.toPushArgs( + message: 'Snappy SPA Zoom-Fade (0.95->1.0) with fastEasing', + source: 'Web Micro-Transition', + transition: RouteTransition.fade_scale, + ).push(context), + ), + const SizedBox(height: 20), + + // --- Custom Transitions --- + const _SectionTitle(title: '🎨 Custom Transition Builders'), + _ExampleButton( + text: 'CustomBuildTransition (Rotation + Elastic Zoom)', + onTap: () => AppTrace.toCustomTransition( + name: 'Rotation & EaseOutBack Elastic Scale', + ).push(context), + ), + const SizedBox(height: 20), + + // --- Adaptive Transitions --- + const _SectionTitle(title: '📱 Platform-Adaptive Transition'), + _ExampleButton( + text: 'Browser.defaultAdaptiveTransition (Platform Auto-Detect)', + onTap: () { + final adaptive = Browser.defaultAdaptiveTransition( + BrowserRoute( + path: AppPath.pushArgs.path, + page: const SizedBox(), + ), + ); + AppTrace.toPushArgs( + message: 'Auto-detected adaptive transition: ${adaptive.name}', + source: 'Browser.defaultAdaptiveTransition', + transition: adaptive, + ).push(context); + }, + ), + const SizedBox(height: 20), + + // --- Presentations & Modals --- + const _SectionTitle(title: '🪟 Presentation (TraceRoute) Styles'), + _ExampleButton( + text: 'Present as Popup (Scale Transition)', + onTap: () => AppTrace.asPopup(transition: RouteTransition.scale).push(context), + ), + _ExampleButton( + text: 'Present as Popup (Fade Scale Transition)', + onTap: () => AppTrace.asPopup(transition: RouteTransition.fade_scale).push(context), + ), + _ExampleButton( + text: 'Present as Swipeable Bottom Sheet', + onTap: () => AppTrace.asSheet().push(context), + ), + const SizedBox(height: 20), + + // --- Legacy Presets --- + const _SectionTitle(title: '📦 Legacy Slide & Fade Presets'), + _ExampleButton( + text: 'Slide Right', + onTap: () => AppTrace.toPushArgs( + message: 'Legacy slide_right transition', + source: 'Legacy Presets', + transition: RouteTransition.slide_right, + ).push(context), + ), + _ExampleButton( + text: 'Slide Left', + onTap: () => AppTrace.toPushArgs( + message: 'Legacy slide_left transition', + source: 'Legacy Presets', + transition: RouteTransition.slide_left, + ).push(context), + ), + _ExampleButton( + text: 'Slide Up', + onTap: () => AppTrace.toPushArgs( + message: 'Legacy slide_up transition', + source: 'Legacy Presets', + transition: RouteTransition.slide_up, + ).push(context), + ), + _ExampleButton( + text: 'Slide Down', + onTap: () => AppTrace.toPushArgs( + message: 'Legacy slide_down transition', + source: 'Legacy Presets', + transition: RouteTransition.slide_down, + ).push(context), + ), + _ExampleButton( + text: 'Fade', + onTap: () => AppTrace.toPushArgs( + message: 'Legacy fade transition', + source: 'Legacy Presets', + transition: RouteTransition.fade, + ).push(context), + ), + _ExampleButton( + text: 'None (Instant)', + onTap: () => AppTrace.toPushArgs( + message: 'Instant transition without animation', + source: 'Legacy Presets', + transition: RouteTransition.none, + ).push(context), + ), + const SizedBox(height: 20), + // --- Argument Examples --- - const _SectionTitle(title: 'Argument & DeepLink Examples'), + const _SectionTitle(title: '🔗 Arguments & DeepLink Examples'), _ExampleButton( - text: '1. Push with Arguments', + text: '1. Push with Typed Arguments', onTap: () => AppTrace.toPushArgs( message: 'Hello from pushNamed()', source: 'args', @@ -81,7 +251,7 @@ class _HomeScreenState extends State { onTap: () { context ..setPushArgument( - PushArgs( + const PushArgs( message: 'Hello from setPushArgument()', source: 'setPushArgument', ), @@ -113,69 +283,120 @@ class _HomeScreenState extends State { text: '7. Receive Arguments from dismissed view', onTap: () => AppTrace.toSetPopArgs().push(context), ), - const SizedBox(height: 24), - - // --- Semantic Navigation (Trace) Example --- - const _SectionTitle( - title: 'Semantic Navigation (Trace) Example', - ), - _ExampleButton( - text: 'Push with a Trace Object', - onTap: () => AppTrace.toPushArgs( - message: 'Hello from a Trace object!', - source: 'AppTrace', - ).push(context), - ), - const SizedBox(height: 24), + const SizedBox(height: 20), - // --- Presentation (TraceRoute) Examples --- - const _SectionTitle( - title: 'Presentation (TraceRoute) Examples', - ), + // --- Overlay --- + const _SectionTitle(title: '📣 Overlay & Banners'), _ExampleButton( - text: 'Present as a Popup', - onTap: () => AppTrace.asPopup().push(context), + text: 'Enqueue Banner (Auto-dismiss 5s)', + onTap: () => Browser.enqueueBanner( + context, + (removableCallback) { + return const ColoredBox( + color: Color(0xFFF59E0B), + child: Padding( + padding: EdgeInsets.all(12), + child: Text( + '🔔 Real-time Notification Banner (Auto-closes in 5s)', + style: TextStyle( + color: Color(0xFF1E293B), + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + ), + ); + }, + topPadding: 0, + ), ), _ExampleButton( - text: 'Present as a sliding Sheet', - onTap: () => AppTrace.asSheet().push(context), - ), - const SizedBox(height: 24), - - // --- Transition Examples --- - const _SectionTitle(title: 'Transition Examples (as Page)'), - ...RouteTransition.values.map((transition) { - return _ExampleButton( - text: 'Push with ${transition.name}', - onTap: () => AppTrace.toPushArgs( - message: 'Pushed with ${transition.name}', - source: 'Transition Example', - transition: transition, - ).push(context), - ); - }), - const SizedBox(height: 24), - // --- overlay --- - const _SectionTitle( - title: 'Overlay', - ), - _ExampleButton( - text: 'EnqueueBanner', + text: 'Enqueue Interactive Banner (Duration 0 + Close Button)', onTap: () => Browser.enqueueBanner( context, (removableCallback) { - return const ColoredBox( - color: Colors.amber, + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), child: Row( children: [ - _SectionTitle( - title: 'Overlay', + Container( + width: 26, + height: 26, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Color(0xFF38BDF8), + shape: BoxShape.circle, + ), + child: const Text( + 'i', + style: TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.w900, + fontSize: 15, + ), + textDirection: TextDirection.ltr, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Persistent Banner (Scroll & Click work below)', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.w600, + fontSize: 14, + ), + textDirection: TextDirection.ltr, + ), + ), + const SizedBox(width: 8), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: removableCallback, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color(0xFF38BDF8), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'Close ✕', + style: TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.bold, + fontSize: 13, + ), + textDirection: TextDirection.ltr, + ), + ), ), ], ), ); }, topPadding: 0, + duration: Duration.zero, ), ), ], @@ -196,12 +417,12 @@ class _SectionTitle extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.only(bottom: 8, top: 8), + padding: const EdgeInsets.only(bottom: 8, top: 12), child: Text( title, style: const TextStyle( color: Color(0xFFFFFFFF), - fontSize: 20, + fontSize: 18, fontWeight: FontWeight.bold, ), textDirection: TextDirection.ltr, @@ -224,12 +445,19 @@ class _ExampleButton extends StatelessWidget { onTap: onTap, child: Container( width: double.infinity, - color: const Color(0xFFFFFFFF), - padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Text( text, textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF000000), fontSize: 16), + style: const TextStyle( + color: Color(0xFF000000), + fontSize: 15, + fontWeight: FontWeight.w500, + ), textDirection: TextDirection.ltr, ), ), diff --git a/example/lib/push_args.dart b/example/lib/push_args.dart index 86d479e..2099e3b 100644 --- a/example/lib/push_args.dart +++ b/example/lib/push_args.dart @@ -1,7 +1,7 @@ import 'package:browser_router/browser.dart'; final class PushArgs extends RouteParams { - new({ + const new({ required this.message, required this.source, }); diff --git a/lib/browser.dart b/lib/browser.dart index 2be0666..b0bebac 100644 --- a/lib/browser.dart +++ b/lib/browser.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:nested/nested.dart'; @@ -132,6 +133,21 @@ class Browser extends StatelessWidget { static final RouteObserver> routeObserver = RouteObserver>(); + /// Default platform-adaptive route transition strategy: + /// - Web / Desktop (Windows, Linux): `RouteTransition.fade_scale` for a snappy SPA feel. + /// - iOS / macOS: `RouteTransition.slide_cupertino` for native parallax slide and edge shadow. + /// - Android / Fuchsia: `RouteTransition.shared_axis_x` for Material 3 directional motion. + static RouteTransition defaultAdaptiveTransition(BrowserRoute route) { + if (kIsWeb) { + return RouteTransition.fade_scale; + } + return switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.macOS => RouteTransition.slide_cupertino, + TargetPlatform.android || TargetPlatform.fuchsia => RouteTransition.shared_axis_x, + TargetPlatform.windows || TargetPlatform.linux => RouteTransition.fade_scale, + }; + } + /// The core route generation logic for the browser. /// /// This function is intended to be used as the `onGenerateRoute` for your @@ -355,11 +371,13 @@ class Browser extends StatelessWidget { BuildContext context, ContentBuilder content, { double? topPadding, + Duration? duration = const Duration(seconds: 5), }) => OverlayManager.enqueueBanner( context, content: content, topPadding: topPadding, + duration: duration, ); /// Dismisses the currently visible overlay modal. diff --git a/lib/browser_route.dart b/lib/browser_route.dart index 95de44f..6e3b6e7 100755 --- a/lib/browser_route.dart +++ b/lib/browser_route.dart @@ -15,6 +15,7 @@ class BrowserRoute extends Equatable { this.builderTrigger, this.validateArguments, this.routeTransition = RouteTransition.slide_right, + this.customTransition, }); /// The unique path for this route (e.g., '/profile'). @@ -27,6 +28,10 @@ class BrowserRoute extends Equatable { /// This can be overridden by a [TraceRoute] during navigation. final RouteTransition routeTransition; + /// An optional custom [BuildTransition] to build custom animations for this route. + /// If provided, this takes precedence over [routeTransition]. + final BuildTransition? customTransition; + /// An optional function that is executed just before the [page] widget is /// built. /// @@ -60,6 +65,7 @@ class BrowserRoute extends Equatable { String? path, Widget? page, RouteTransition? routeTransition, + BuildTransition? customTransition, void Function(BuildContext context)? builderTrigger, bool Function(Check checkArgument, GetArguments getArgument)? validateArguments, @@ -68,6 +74,7 @@ class BrowserRoute extends Equatable { page: page ?? this.page, path: path ?? this.path, routeTransition: routeTransition ?? this.routeTransition, + customTransition: customTransition ?? this.customTransition, builderTrigger: builderTrigger ?? this.builderTrigger, validateArguments: validateArguments ?? this.validateArguments, ); diff --git a/lib/modal_route/browser_page_route.dart b/lib/modal_route/browser_page_route.dart index 3dddc5c..bdc6026 100755 --- a/lib/modal_route/browser_page_route.dart +++ b/lib/modal_route/browser_page_route.dart @@ -1,5 +1,5 @@ import 'package:browser_router/gestures/swipe/swipe.dart'; -import 'package:flutter/foundation.dart'; // Import for debugPrint +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../browser.dart'; @@ -13,11 +13,14 @@ class BrowserPageRoute extends PageRoute required this.appRoute, required this.traceRoute, super.settings, - }) : transitionDuration = appRoute.routeTransition == RouteTransition.none - ? Duration.zero - : traceRoute.transitionDuration, + }) : transitionDuration = + (traceRoute.routeTransition ?? appRoute.routeTransition) == + RouteTransition.none + ? Duration.zero + : traceRoute.transitionDuration, reverseTransitionDuration = - appRoute.routeTransition == RouteTransition.none + (traceRoute.routeTransition ?? appRoute.routeTransition) == + RouteTransition.none ? Duration.zero : traceRoute.reverseTransitionDuration, barrierLabel = traceRoute.barrierLabel, @@ -89,8 +92,22 @@ class BrowserPageRoute extends PageRoute Animation secondaryAnimation, Widget child, ) { + // Accessibility: bypass animations if user preferences require reduced motion + if (MediaQuery.disableAnimationsOf(context) || + MediaQuery.accessibleNavigationOf(context)) { + return child; + } + + final isolatedChild = RepaintBoundary(child: child); + final buildTransition = traceRoute.customTransition ?? + appRoute.customTransition ?? + (traceRoute.routeTransition ?? appRoute.routeTransition).build; + + final isSlide = appRoute.routeTransition == RouteTransition.slide_right || + appRoute.routeTransition == RouteTransition.slide_cupertino; + if (defaultTargetPlatform == TargetPlatform.iOS && - appRoute.routeTransition == RouteTransition.slide_right && + isSlide && traceRoute.popGestureEnabled && context.navigate.canPop()) { final swipeGestures = SwipeDownRightGestures( @@ -113,18 +130,18 @@ class BrowserPageRoute extends PageRoute gestures: swipeGestures, disableAnimations: MediaQuery.disableAnimationsOf(context), animateChild: false, - child: appRoute.routeTransition.build( + child: buildTransition( animation: animation, secondaryAnimation: secondaryAnimation, - child: child, + child: isolatedChild, ), ); } - return appRoute.routeTransition.build( + return buildTransition( animation: animation, secondaryAnimation: secondaryAnimation, - child: child, + child: isolatedChild, ); } } diff --git a/lib/modal_route/browser_popup_route.dart b/lib/modal_route/browser_popup_route.dart index 7e90597..e5f843f 100644 --- a/lib/modal_route/browser_popup_route.dart +++ b/lib/modal_route/browser_popup_route.dart @@ -7,13 +7,21 @@ export 'params/trace_route.dart' show PopupTraceRoute; class BrowserPopupRoute extends PopupRoute with BrowserModalBarrierMixin { - /// A modal bottom sheet route. + /// A modal popup route. new({ required this.traceRoute, required this.appRoute, super.settings, - }) : transitionDuration = traceRoute.transitionDuration, - reverseTransitionDuration = traceRoute.reverseTransitionDuration, + }) : transitionDuration = + (traceRoute.routeTransition ?? appRoute.routeTransition) == + RouteTransition.none + ? Duration.zero + : traceRoute.transitionDuration, + reverseTransitionDuration = + (traceRoute.routeTransition ?? appRoute.routeTransition) == + RouteTransition.none + ? Duration.zero + : traceRoute.reverseTransitionDuration, barrierLabel = traceRoute.barrierLabel, maintainState = traceRoute.maintainState, opaque = traceRoute.opaque, @@ -79,11 +87,20 @@ class BrowserPopupRoute extends PopupRoute Animation secondaryAnimation, Widget child, ) { - final transitions = traceRoute.routeTransition ?? appRoute.routeTransition; - return transitions.build( + if (MediaQuery.disableAnimationsOf(context) || + MediaQuery.accessibleNavigationOf(context)) { + return child; + } + + final isolatedChild = RepaintBoundary(child: child); + final buildTransition = traceRoute.customTransition ?? + appRoute.customTransition ?? + (traceRoute.routeTransition ?? appRoute.routeTransition).build; + + return buildTransition( animation: animation, secondaryAnimation: secondaryAnimation, - child: child, + child: isolatedChild, ); } } diff --git a/lib/modal_route/params/overlay_trace_route.dart b/lib/modal_route/params/overlay_trace_route.dart index 197bf48..2b37adf 100644 --- a/lib/modal_route/params/overlay_trace_route.dart +++ b/lib/modal_route/params/overlay_trace_route.dart @@ -3,6 +3,7 @@ part of 'trace_route.dart'; final class OverlayTraceRoute extends TraceRoute { const new({ super.routeTransition, + super.customTransition, super.opaque, super.maintainState, super.allowSnapshotting, diff --git a/lib/modal_route/params/page_trace_route.dart b/lib/modal_route/params/page_trace_route.dart index 81f116f..77a039a 100644 --- a/lib/modal_route/params/page_trace_route.dart +++ b/lib/modal_route/params/page_trace_route.dart @@ -3,6 +3,7 @@ part of 'trace_route.dart'; final class PageTraceRoute extends TraceRoute { const new({ super.routeTransition, + super.customTransition, super.opaque = true, super.maintainState = true, super.filter, diff --git a/lib/modal_route/params/popup_trace_route.dart b/lib/modal_route/params/popup_trace_route.dart index 5556b3e..56c551f 100644 --- a/lib/modal_route/params/popup_trace_route.dart +++ b/lib/modal_route/params/popup_trace_route.dart @@ -3,6 +3,7 @@ part of 'trace_route.dart'; final class PopupTraceRoute extends TraceRoute { const new({ super.routeTransition, + super.customTransition, super.opaque = false, super.maintainState = true, super.allowSnapshotting = false, diff --git a/lib/modal_route/params/swipe_trace_route.dart b/lib/modal_route/params/swipe_trace_route.dart index c6979cb..7873dc2 100644 --- a/lib/modal_route/params/swipe_trace_route.dart +++ b/lib/modal_route/params/swipe_trace_route.dart @@ -3,6 +3,7 @@ part of 'trace_route.dart'; final class SwipeTraceRoute extends PopupTraceRoute { const new({ super.routeTransition, + super.customTransition, super.opaque, super.maintainState, super.allowSnapshotting, diff --git a/lib/modal_route/params/trace_route.dart b/lib/modal_route/params/trace_route.dart index decaf5a..512e0b9 100644 --- a/lib/modal_route/params/trace_route.dart +++ b/lib/modal_route/params/trace_route.dart @@ -23,6 +23,7 @@ part 'swipe_trace_route.dart'; sealed class TraceRoute { const new({ this.routeTransition, + this.customTransition, this.opaque = false, this.maintainState = true, this.allowSnapshotting = false, @@ -40,6 +41,7 @@ sealed class TraceRoute { /// full-screen page. factory page({ RouteTransition? routeTransition, + BuildTransition? customTransition, bool opaque = true, bool maintainState = true, bool allowSnapshotting = true, @@ -56,6 +58,7 @@ sealed class TraceRoute { }) => PageTraceRoute( routeTransition: routeTransition, + customTransition: customTransition, opaque: opaque, maintainState: maintainState, allowSnapshotting: allowSnapshotting, @@ -75,6 +78,7 @@ sealed class TraceRoute { /// or popup. factory popup({ RouteTransition? routeTransition, + BuildTransition? customTransition, bool opaque = false, bool maintainState = true, bool allowSnapshotting = false, @@ -89,6 +93,7 @@ sealed class TraceRoute { }) => PopupTraceRoute( routeTransition: routeTransition, + customTransition: customTransition, opaque: opaque, maintainState: maintainState, allowSnapshotting: allowSnapshotting, @@ -106,6 +111,7 @@ sealed class TraceRoute { /// bottom sheet. factory swipe({ RouteTransition? routeTransition, + BuildTransition? customTransition, bool opaque = false, bool maintainState = true, bool allowSnapshotting = false, @@ -125,6 +131,7 @@ sealed class TraceRoute { }) => SwipeTraceRoute( routeTransition: routeTransition, + customTransition: customTransition, opaque: opaque, maintainState: maintainState, allowSnapshotting: allowSnapshotting, @@ -147,6 +154,9 @@ sealed class TraceRoute { /// If null, the default transition from `BrowserRoute` is used. final RouteTransition? routeTransition; + /// An optional custom [BuildTransition] to override the transition animation. + final BuildTransition? customTransition; + /// {@template flutter.widgets.ModalRoute.opaque} /// Whether this route obscures the route beneath it. /// diff --git a/lib/modal_route/transitions/build_transitions/custom_build_transition.dart b/lib/modal_route/transitions/build_transitions/custom_build_transition.dart new file mode 100644 index 0000000..7fb2c15 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/custom_build_transition.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; + +import 'build_transition.dart'; + +/// Function signature for custom transition builders. +typedef CustomTransitionBuilder = Widget Function( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, +); + +/// A [BuildTransition] that delegates to a custom builder function. +final class CustomBuildTransition implements BuildTransition { + const new(this.builder); + + final Widget Function({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) builder; + + @override + Widget call({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) { + return builder( + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/fade_scale_build_transition.dart b/lib/modal_route/transitions/build_transitions/fade_scale_build_transition.dart new file mode 100644 index 0000000..db84dc2 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/fade_scale_build_transition.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; + +import 'build_transition.dart'; +import 'widgets/fade_scale.dart'; + +final class FadeScaleBuildTransition implements BuildTransition { + const new(); + + @override + Widget call({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) { + return FadeScale( + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/fade_through_build_transition.dart b/lib/modal_route/transitions/build_transitions/fade_through_build_transition.dart new file mode 100644 index 0000000..64ba568 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/fade_through_build_transition.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; + +import 'build_transition.dart'; +import 'widgets/fade_through.dart'; + +final class FadeThroughBuildTransition implements BuildTransition { + const new({this.fillColor}); + + final Color? fillColor; + + @override + Widget call({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) { + return FadeThrough( + animation: animation, + secondaryAnimation: secondaryAnimation, + fillColor: fillColor, + child: child, + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/scale_build_transition.dart b/lib/modal_route/transitions/build_transitions/scale_build_transition.dart new file mode 100644 index 0000000..f1ce3a8 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/scale_build_transition.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; + +import 'build_transition.dart'; +import 'widgets/scale.dart'; + +final class ScaleBuildTransition implements BuildTransition { + const new({this.initialScale = 0.85}); + + final double initialScale; + + @override + Widget call({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) { + return Scale( + animation: animation, + secondaryAnimation: secondaryAnimation, + initialScale: initialScale, + child: child, + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/shared_axis_build_transition.dart b/lib/modal_route/transitions/build_transitions/shared_axis_build_transition.dart new file mode 100644 index 0000000..aee94c3 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/shared_axis_build_transition.dart @@ -0,0 +1,26 @@ +import 'package:flutter/widgets.dart'; + +import 'build_transition.dart'; +import 'widgets/shared_axis.dart'; + +export 'widgets/shared_axis.dart' show SharedAxisDirection; + +final class SharedAxisBuildTransition implements BuildTransition { + const new({this.direction = SharedAxisDirection.horizontal}); + + final SharedAxisDirection direction; + + @override + Widget call({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) { + return SharedAxis( + animation: animation, + secondaryAnimation: secondaryAnimation, + direction: direction, + child: child, + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/slide_cupertino_build_transition.dart b/lib/modal_route/transitions/build_transitions/slide_cupertino_build_transition.dart new file mode 100644 index 0000000..0755399 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/slide_cupertino_build_transition.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; + +import 'build_transition.dart'; +import 'widgets/slide_cupertino.dart'; + +final class SlideCupertinoBuildTransition implements BuildTransition { + const new({this.hasShadow = true}); + + final bool hasShadow; + + @override + Widget call({ + required Animation animation, + required Animation secondaryAnimation, + required Widget child, + }) { + return SlideCupertino( + animation: animation, + secondaryAnimation: secondaryAnimation, + hasShadow: hasShadow, + child: child, + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/widgets/fade_scale.dart b/lib/modal_route/transitions/build_transitions/widgets/fade_scale.dart new file mode 100644 index 0000000..e8884d3 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/widgets/fade_scale.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; + +/// Modern Web & Material 3 Fade Scale Transition. +/// +/// Entering surfaces scale smoothly from 0.95 -> 1.0 while fading in (0.0 -> 1.0). +/// Exiting surfaces scale down to 0.95 and fade out (1.0 -> 0.0). +class FadeScale extends StatelessWidget { + new({ + required this.animation, + required this.secondaryAnimation, + required this.child, + super.key, + }) : _primaryOpacity = CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + _primaryScale = Tween(begin: 0.95, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ), + _secondaryOpacity = Tween(begin: 1, end: 0).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ), + _secondaryScale = Tween(begin: 1, end: 0.95).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + final Animation animation; + final Animation secondaryAnimation; + final Widget child; + + final Animation _primaryOpacity; + final Animation _primaryScale; + final Animation _secondaryOpacity; + final Animation _secondaryScale; + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _secondaryOpacity, + child: ScaleTransition( + scale: _secondaryScale, + child: FadeTransition( + opacity: _primaryOpacity, + child: ScaleTransition( + scale: _primaryScale, + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/widgets/fade_through.dart b/lib/modal_route/transitions/build_transitions/widgets/fade_through.dart new file mode 100644 index 0000000..ec3146f --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/widgets/fade_through.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; + +/// Material 3 & Modern Web Fade Through Transition. +/// +/// Top-level destinations fade out while scaling down slightly (1.0 -> 0.96), +/// and incoming destinations fade in while scaling up (0.92 -> 1.0). +class FadeThrough extends StatelessWidget { + new({ + required this.animation, + required this.secondaryAnimation, + required this.child, + this.fillColor, + super.key, + }) : _primaryOpacity = CurvedAnimation( + parent: animation, + curve: const Interval(0.35, 1, curve: Curves.fastEaseInToSlowEaseOut), + ), + _primaryScale = Tween(begin: 0.92, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: const Interval(0.35, 1, curve: Curves.fastEaseInToSlowEaseOut), + ), + ), + _secondaryOpacity = Tween(begin: 1, end: 0).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: const Interval(0, 0.35, curve: Curves.fastEaseInToSlowEaseOut), + ), + ), + _secondaryScale = Tween(begin: 1, end: 0.96).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: const Interval(0, 0.35, curve: Curves.fastEaseInToSlowEaseOut), + ), + ); + + final Animation animation; + final Animation secondaryAnimation; + final Widget child; + final Color? fillColor; + + final Animation _primaryOpacity; + final Animation _primaryScale; + final Animation _secondaryOpacity; + final Animation _secondaryScale; + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _secondaryOpacity, + child: ScaleTransition( + scale: _secondaryScale, + child: FadeTransition( + opacity: _primaryOpacity, + child: ScaleTransition( + scale: _primaryScale, + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/widgets/scale.dart b/lib/modal_route/transitions/build_transitions/widgets/scale.dart new file mode 100644 index 0000000..16acb11 --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/widgets/scale.dart @@ -0,0 +1,53 @@ +import 'package:flutter/widgets.dart'; + +/// Clean Scale & Zoom transition for dialogs and popups. +class Scale extends StatelessWidget { + new({ + required this.animation, + required this.secondaryAnimation, + required this.child, + this.initialScale = 0.85, + super.key, + }) : _scale = Tween(begin: initialScale, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ), + _opacity = CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ), + _secondaryOpacity = Tween(begin: 1, end: 0).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.easeOut, + reverseCurve: Curves.easeIn, + ), + ); + + final Animation animation; + final Animation secondaryAnimation; + final Widget child; + final double initialScale; + + final Animation _scale; + final Animation _opacity; + final Animation _secondaryOpacity; + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _secondaryOpacity, + child: FadeTransition( + opacity: _opacity, + child: ScaleTransition( + scale: _scale, + child: child, + ), + ), + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/widgets/shared_axis.dart b/lib/modal_route/transitions/build_transitions/widgets/shared_axis.dart new file mode 100644 index 0000000..ca8212c --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/widgets/shared_axis.dart @@ -0,0 +1,157 @@ +import 'package:flutter/widgets.dart'; + +enum SharedAxisDirection { + horizontal, + vertical, + scaled, +} + +/// Material 3 Shared Axis Transition. +/// +/// Implements directional motion along the X (horizontal), Y (vertical), or Z (scaled) axis. +class SharedAxis extends StatelessWidget { + new({ + required this.animation, + required this.secondaryAnimation, + required this.child, + this.direction = SharedAxisDirection.horizontal, + super.key, + }) : _primaryOpacity = CurvedAnimation( + parent: animation, + curve: const Interval(0.1, 1, curve: Curves.fastEaseInToSlowEaseOut), + ), + _secondaryOpacity = Tween(begin: 1, end: 0).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: const Interval(0, 0.4, curve: Curves.fastEaseInToSlowEaseOut), + ), + ); + + final Animation animation; + final Animation secondaryAnimation; + final Widget child; + final SharedAxisDirection direction; + + final Animation _primaryOpacity; + final Animation _secondaryOpacity; + + @override + Widget build(BuildContext context) { + return switch (direction) { + SharedAxisDirection.horizontal => _buildHorizontal(context), + SharedAxisDirection.vertical => _buildVertical(context), + SharedAxisDirection.scaled => _buildScaled(context), + }; + } + + Widget _buildHorizontal(BuildContext context) { + final textDirection = Directionality.of(context); + final isRtl = textDirection == TextDirection.rtl; + + final primaryOffset = Tween( + begin: Offset(isRtl ? -0.3 : 0.3, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + final secondaryOffset = Tween( + begin: Offset.zero, + end: Offset(isRtl ? 0.3 : -0.3, 0), + ).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + return SlideTransition( + position: secondaryOffset, + child: FadeTransition( + opacity: _secondaryOpacity, + child: SlideTransition( + position: primaryOffset, + child: FadeTransition( + opacity: _primaryOpacity, + child: child, + ), + ), + ), + ); + } + + Widget _buildVertical(BuildContext context) { + final primaryOffset = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + final secondaryOffset = Tween( + begin: Offset.zero, + end: const Offset(0, -0.3), + ).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + return SlideTransition( + position: secondaryOffset, + child: FadeTransition( + opacity: _secondaryOpacity, + child: SlideTransition( + position: primaryOffset, + child: FadeTransition( + opacity: _primaryOpacity, + child: child, + ), + ), + ), + ); + } + + Widget _buildScaled(BuildContext context) { + final primaryScale = Tween(begin: 0.8, end: 1).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + final secondaryScale = Tween(begin: 1, end: 1.1).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ); + + return ScaleTransition( + scale: secondaryScale, + child: FadeTransition( + opacity: _secondaryOpacity, + child: ScaleTransition( + scale: primaryScale, + child: FadeTransition( + opacity: _primaryOpacity, + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/modal_route/transitions/build_transitions/widgets/slide_cupertino.dart b/lib/modal_route/transitions/build_transitions/widgets/slide_cupertino.dart new file mode 100644 index 0000000..ac98aef --- /dev/null +++ b/lib/modal_route/transitions/build_transitions/widgets/slide_cupertino.dart @@ -0,0 +1,76 @@ +import 'package:flutter/widgets.dart'; + +/// Authentic iOS-style Cupertino full-page slide transition. +/// +/// Features a native right-to-left slide with a vertical gradient shadow on the +/// leading edge, and 1/3 horizontal parallax on the exiting route. +class SlideCupertino extends StatelessWidget { + new({ + required this.animation, + required this.secondaryAnimation, + required this.child, + this.hasShadow = true, + super.key, + }) : _primaryPosition = Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + ), + ), + _secondaryPosition = Tween( + begin: Offset.zero, + end: const Offset(-1 / 3, 0), + ).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + ), + ); + + final Animation animation; + final Animation secondaryAnimation; + final Widget child; + final bool hasShadow; + + final Animation _primaryPosition; + final Animation _secondaryPosition; + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.of(context); + + var content = child; + if (hasShadow) { + content = DecoratedBox( + position: DecorationPosition.background, + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: Color(0x26000000), + blurRadius: 16, + spreadRadius: 2, + offset: Offset(-4, 0), + ), + ], + ), + child: content, + ); + } + + return SlideTransition( + position: _secondaryPosition, + textDirection: textDirection, + transformHitTests: false, + child: SlideTransition( + position: _primaryPosition, + textDirection: textDirection, + child: content, + ), + ); + } +} diff --git a/lib/modal_route/transitions/route_transition.dart b/lib/modal_route/transitions/route_transition.dart index 038e323..089c941 100644 --- a/lib/modal_route/transitions/route_transition.dart +++ b/lib/modal_route/transitions/route_transition.dart @@ -1,13 +1,50 @@ import 'build_transitions/build_transition.dart'; import 'build_transitions/fade_build_transition.dart'; +import 'build_transitions/fade_scale_build_transition.dart'; +import 'build_transitions/fade_through_build_transition.dart'; import 'build_transitions/no_build_transition.dart'; +import 'build_transitions/scale_build_transition.dart'; +import 'build_transitions/shared_axis_build_transition.dart'; import 'build_transitions/slide_build_transition.dart'; +import 'build_transitions/slide_cupertino_build_transition.dart'; import 'build_transitions/widgets/slide.dart'; +export 'build_transitions/build_transition.dart'; +export 'build_transitions/custom_build_transition.dart'; +export 'build_transitions/fade_build_transition.dart'; +export 'build_transitions/fade_scale_build_transition.dart'; +export 'build_transitions/fade_through_build_transition.dart'; +export 'build_transitions/no_build_transition.dart'; +export 'build_transitions/scale_build_transition.dart'; +export 'build_transitions/shared_axis_build_transition.dart'; +export 'build_transitions/slide_build_transition.dart'; +export 'build_transitions/slide_cupertino_build_transition.dart'; + enum RouteTransition { fade( build: FadeBuildTransition(), ), + fade_through( + build: FadeThroughBuildTransition(), + ), + fade_scale( + build: FadeScaleBuildTransition(), + ), + shared_axis_x( + build: SharedAxisBuildTransition(direction: SharedAxisDirection.horizontal), + ), + shared_axis_y( + build: SharedAxisBuildTransition(direction: SharedAxisDirection.vertical), + ), + shared_axis_z( + build: SharedAxisBuildTransition(direction: SharedAxisDirection.scaled), + ), + scale( + build: ScaleBuildTransition(), + ), + slide_cupertino( + build: SlideCupertinoBuildTransition(), + ), slide_right( build: SlideBuildTransition( position: Positions.right, diff --git a/lib/overlay/banner.dart b/lib/overlay/banner.dart index 1cde63b..71dd9ae 100644 --- a/lib/overlay/banner.dart +++ b/lib/overlay/banner.dart @@ -33,22 +33,18 @@ class Banner extends OverlayModal { OverlayEntry _createModal(Widget child) { return OverlayEntry( builder: (context) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - child: SafeArea( - child: Padding( - padding: EdgeInsets.only(top: topPadding), - child: Align( - alignment: Alignment.topCenter, - child: Dismissible( - onDismissed: (direction) { - // animationController?.reverse(); - remove(); - }, - key: UniqueKey(), - direction: DismissDirection.up, - child: child, - ), + return SafeArea( + child: Padding( + padding: EdgeInsets.only(top: topPadding), + child: Align( + alignment: Alignment.topCenter, + child: Dismissible( + onDismissed: (direction) { + remove(); + }, + key: UniqueKey(), + direction: DismissDirection.up, + child: child, ), ), ), diff --git a/lib/overlay/overlay_manager.dart b/lib/overlay/overlay_manager.dart index aab6897..2848caa 100755 --- a/lib/overlay/overlay_manager.dart +++ b/lib/overlay/overlay_manager.dart @@ -44,11 +44,12 @@ class OverlayManager extends StatefulWidget { BuildContext context, { required ContentBuilder content, double? topPadding, + Duration? duration = const Duration(seconds: 5), }) { of(context)?.enqueue( Banner.fromContext( context: context, - duration: const Duration(seconds: 5), + duration: duration, transition: const OverlayTraceRoute( routeTransition: RouteTransition.slide_down, reverseTransitionDuration: Duration(milliseconds: 300), diff --git a/lib/overlay/overlay_modal.dart b/lib/overlay/overlay_modal.dart index 92e81a3..2bac740 100644 --- a/lib/overlay/overlay_modal.dart +++ b/lib/overlay/overlay_modal.dart @@ -33,7 +33,7 @@ abstract class OverlayModal { /// the child widget is a content wrapped with transition animation OverlayEntry _createModal(Widget child); Animation _createAnimation() => _animationController.view; - Animation _createSecondaryAnimation() => _animationController.view; + Animation _createSecondaryAnimation() => kAlwaysDismissedAnimation; /// create animation and overlayEntry an insert to overlayState Future insert() async { @@ -53,7 +53,7 @@ abstract class OverlayModal { if (_isDisposed) return; - if (duration is Duration) { + if (duration is Duration && duration != Duration.zero) { timer = Timer( duration!, remove, diff --git a/pubspec.yaml b/pubspec.yaml index 80bfd5c..4a48513 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: browser_router description: An advanced navigation system for Flutter that enables typed routes, custom transitions, and robust overlay management. -version: 0.2.0 +version: 0.3.0 repository: https://github.com/coolosos/browser_router homepage: https://github.com/coolosos/browser_router issue_tracker: https://github.com/coolosos/browser_router/issues diff --git a/test/modern_route_transitions_integration_test.dart b/test/modern_route_transitions_integration_test.dart new file mode 100644 index 0000000..d532bf8 --- /dev/null +++ b/test/modern_route_transitions_integration_test.dart @@ -0,0 +1,423 @@ +import 'package:browser_router/browser.dart'; +import 'package:browser_router/modal_route/browser_page_route.dart'; +import 'package:browser_router/modal_route/browser_popup_route.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Browser.defaultAdaptiveTransition Tests', () { + test('returns platform-appropriate transitions', () { + const sampleRoute = BrowserRoute( + path: '/test', + page: SizedBox(), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + expect( + Browser.defaultAdaptiveTransition(sampleRoute), + equals(RouteTransition.slide_cupertino), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + expect( + Browser.defaultAdaptiveTransition(sampleRoute), + equals(RouteTransition.slide_cupertino), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.android; + expect( + Browser.defaultAdaptiveTransition(sampleRoute), + equals(RouteTransition.shared_axis_x), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; + expect( + Browser.defaultAdaptiveTransition(sampleRoute), + equals(RouteTransition.shared_axis_x), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + expect( + Browser.defaultAdaptiveTransition(sampleRoute), + equals(RouteTransition.fade_scale), + ); + + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + expect( + Browser.defaultAdaptiveTransition(sampleRoute), + equals(RouteTransition.fade_scale), + ); + + debugDefaultTargetPlatformOverride = null; + }); + }); + + group('Modern Transitions Integration in BrowserPageRoute', () { + final modernTransitions = [ + RouteTransition.fade_through, + RouteTransition.fade_scale, + RouteTransition.shared_axis_x, + RouteTransition.shared_axis_y, + RouteTransition.shared_axis_z, + RouteTransition.slide_cupertino, + RouteTransition.scale, + ]; + + for (final transition in modernTransitions) { + testWidgets('BrowserPageRoute builds transition correctly for ${transition.name}', ( + tester, + ) async { + final appRoute = BrowserRoute( + path: '/${transition.name}', + page: Text('Page ${transition.name}', textDirection: TextDirection.ltr), + routeTransition: transition, + ); + const traceRoute = PageTraceRoute(); + final pageRoute = BrowserPageRoute( + appRoute: appRoute, + traceRoute: traceRoute, + settings: RouteSettings(name: '/${transition.name}'), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Builder( + builder: (context) { + return pageRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('Child', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(find.text('Child'), findsOneWidget); + expect(find.byType(RepaintBoundary), findsWidgets); + }); + } + + testWidgets('BrowserPageRoute bypasses transitions when disableAnimations is true', ( + tester, + ) async { + const appRoute = BrowserRoute( + path: '/a11y-disable', + page: Text('Page', textDirection: TextDirection.ltr), + routeTransition: RouteTransition.fade_through, + ); + const traceRoute = PageTraceRoute(); + final pageRoute = BrowserPageRoute( + appRoute: appRoute, + traceRoute: traceRoute, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: Builder( + builder: (context) { + return pageRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('Reduced Motion Child', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(find.text('Reduced Motion Child'), findsOneWidget); + }); + + testWidgets('BrowserPageRoute bypasses transitions when accessibleNavigation is true', ( + tester, + ) async { + const appRoute = BrowserRoute( + path: '/a11y-nav', + page: Text('Page', textDirection: TextDirection.ltr), + routeTransition: RouteTransition.slide_cupertino, + ); + const traceRoute = PageTraceRoute(); + final pageRoute = BrowserPageRoute( + appRoute: appRoute, + traceRoute: traceRoute, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: Builder( + builder: (context) { + return pageRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('A11y Nav Child', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(find.text('A11y Nav Child'), findsOneWidget); + }); + }); + + group('Modern Transitions Integration in BrowserPopupRoute', () { + testWidgets('BrowserPopupRoute builds transition and respects reduced motion', ( + tester, + ) async { + const appRoute = BrowserRoute( + path: '/popup-modern', + page: Text('Popup Page', textDirection: TextDirection.ltr), + routeTransition: RouteTransition.fade_scale, + ); + const traceRoute = PopupTraceRoute(); + final popupRoute = BrowserPopupRoute( + appRoute: appRoute, + traceRoute: traceRoute, + ); + + // Normal animation + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Builder( + builder: (context) { + return popupRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('Popup Child', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(find.text('Popup Child'), findsOneWidget); + expect(find.byType(RepaintBoundary), findsWidgets); + + // Reduced motion + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: Builder( + builder: (context) { + return popupRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('Popup Reduced Motion', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(find.text('Popup Reduced Motion'), findsOneWidget); + }); + }); + + group('Custom Transition Tests', () { + testWidgets('BrowserPageRoute uses customTransition from appRoute', ( + tester, + ) async { + var customBuilderCalled = false; + final appRoute = BrowserRoute( + path: '/custom-app', + page: const Text('Custom Page', textDirection: TextDirection.ltr), + customTransition: CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) { + customBuilderCalled = true; + return Padding( + padding: const EdgeInsets.all(8), + child: child, + ); + }, + ), + ); + const traceRoute = PageTraceRoute(); + final pageRoute = BrowserPageRoute( + appRoute: appRoute, + traceRoute: traceRoute, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Builder( + builder: (context) { + return pageRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('Custom App Child', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(customBuilderCalled, isTrue); + expect(find.text('Custom App Child'), findsOneWidget); + expect(find.byType(Padding), findsOneWidget); + }); + + testWidgets('BrowserPageRoute gives precedence to TraceRoute customTransition', ( + tester, + ) async { + var appRouteBuilderCalled = false; + var traceRouteBuilderCalled = false; + + final appRoute = BrowserRoute( + path: '/precedence', + page: const Text('Page', textDirection: TextDirection.ltr), + customTransition: CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) { + appRouteBuilderCalled = true; + return child; + }, + ), + ); + final traceRoute = PageTraceRoute( + customTransition: CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) { + traceRouteBuilderCalled = true; + return DecoratedBox( + decoration: const BoxDecoration(), + child: child, + ); + }, + ), + ); + final pageRoute = BrowserPageRoute( + appRoute: appRoute, + traceRoute: traceRoute, + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Builder( + builder: (context) { + return pageRoute.buildTransitions( + context, + kAlwaysCompleteAnimation, + kAlwaysDismissedAnimation, + const Text('Precedence Child', textDirection: TextDirection.ltr), + ); + }, + ), + ), + ), + ); + + expect(traceRouteBuilderCalled, isTrue); + expect(appRouteBuilderCalled, isFalse); + expect(find.byType(DecoratedBox), findsOneWidget); + }); + + test('TraceRoute subclasses accept customTransition parameter', () { + final customBuilder = CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) => + child, + ); + + final page = PageTraceRoute(customTransition: customBuilder); + expect(page.customTransition, equals(customBuilder)); + + final popup = PopupTraceRoute(customTransition: customBuilder); + expect(popup.customTransition, equals(customBuilder)); + + final swipe = SwipeTraceRoute(customTransition: customBuilder); + expect(swipe.customTransition, equals(customBuilder)); + + final overlay = OverlayTraceRoute(customTransition: customBuilder); + expect(overlay.customTransition, equals(customBuilder)); + + final factoryPage = TraceRoute.page(customTransition: customBuilder); + expect(factoryPage.customTransition, equals(customBuilder)); + + final factoryPopup = TraceRoute.popup(customTransition: customBuilder); + expect(factoryPopup.customTransition, equals(customBuilder)); + + final factorySwipe = TraceRoute.swipe(customTransition: customBuilder); + expect(factorySwipe.customTransition, equals(customBuilder)); + }); + + test('BrowserRoute copyWith preserves or updates customTransition', () { + final custom1 = CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) => + child, + ); + final custom2 = CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) => + child, + ); + + final route1 = BrowserRoute( + path: '/r1', + page: const SizedBox(), + customTransition: custom1, + ); + expect(route1.customTransition, equals(custom1)); + + final route2 = route1.copyWith(customTransition: custom2); + expect(route2.customTransition, equals(custom2)); + + final route3 = route1.copyWith(path: '/r3'); + expect(route3.customTransition, equals(custom1)); + expect(route3.path, equals('/r3')); + }); + }); +} diff --git a/test/modern_transitions_test.dart b/test/modern_transitions_test.dart new file mode 100644 index 0000000..a4882c0 --- /dev/null +++ b/test/modern_transitions_test.dart @@ -0,0 +1,371 @@ +import 'package:browser_router/browser.dart'; +import 'package:browser_router/modal_route/transitions/build_transitions/widgets/fade_scale.dart'; +import 'package:browser_router/modal_route/transitions/build_transitions/widgets/fade_through.dart'; +import 'package:browser_router/modal_route/transitions/build_transitions/widgets/scale.dart'; +import 'package:browser_router/modal_route/transitions/build_transitions/widgets/shared_axis.dart'; +import 'package:browser_router/modal_route/transitions/build_transitions/widgets/slide_cupertino.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Modern Transitions Widget Tests', () { + testWidgets('FadeThrough renders with animation and secondaryAnimation', ( + tester, + ) async { + late AnimationController primaryController; + late AnimationController secondaryController; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + final vsync = tester; + primaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + secondaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + + return FadeThrough( + animation: primaryController, + secondaryAnimation: secondaryController, + fillColor: const Color(0xFFFFFFFF), + child: const Text('FadeThrough Content'), + ); + }, + ), + ), + ); + + expect(find.text('FadeThrough Content'), findsOneWidget); + + // Verify at t=0.0 + primaryController.value = 0.0; + secondaryController.value = 0.0; + await tester.pump(); + expect(find.text('FadeThrough Content'), findsOneWidget); + + // Verify at t=0.5 + primaryController.value = 0.5; + await tester.pump(); + expect(find.text('FadeThrough Content'), findsOneWidget); + + // Verify at t=1.0 with secondary animation active + primaryController.value = 1.0; + secondaryController.value = 0.5; + await tester.pump(); + expect(find.text('FadeThrough Content'), findsOneWidget); + + primaryController.dispose(); + secondaryController.dispose(); + }); + + testWidgets('FadeScale renders with scale and fade intervals', ( + tester, + ) async { + late AnimationController primaryController; + late AnimationController secondaryController; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + final vsync = tester; + primaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 200), + ); + secondaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 200), + ); + + return FadeScale( + animation: primaryController, + secondaryAnimation: secondaryController, + child: const Text('FadeScale Content'), + ); + }, + ), + ), + ); + + expect(find.text('FadeScale Content'), findsOneWidget); + + primaryController.value = 0.0; + await tester.pump(); + + primaryController.value = 1.0; + secondaryController.value = 0.5; + await tester.pump(); + expect(find.text('FadeScale Content'), findsOneWidget); + + primaryController.dispose(); + secondaryController.dispose(); + }); + + testWidgets('SharedAxis renders horizontal, vertical, and scaled', ( + tester, + ) async { + for (final type in SharedAxisDirection.values) { + for (final direction in [TextDirection.ltr, TextDirection.rtl]) { + late AnimationController primaryController; + late AnimationController secondaryController; + + await tester.pumpWidget( + Directionality( + textDirection: direction, + child: StatefulBuilder( + builder: (context, setState) { + final vsync = tester; + primaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + secondaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + + return SharedAxis( + animation: primaryController, + secondaryAnimation: secondaryController, + direction: type, + child: Text('SharedAxis ${type.name} ${direction.name}'), + ); + }, + ), + ), + ); + + expect(find.text('SharedAxis ${type.name} ${direction.name}'), findsOneWidget); + + primaryController.value = 0.0; + await tester.pump(); + + primaryController.value = 0.5; + await tester.pump(); + + primaryController.value = 1.0; + secondaryController.value = 0.5; + await tester.pump(); + + primaryController.dispose(); + secondaryController.dispose(); + } + } + }); + + testWidgets('SlideCupertino handles shadow and LTR/RTL text directions', ( + tester, + ) async { + for (final hasShadow in [true, false]) { + for (final direction in [TextDirection.ltr, TextDirection.rtl]) { + late AnimationController primaryController; + late AnimationController secondaryController; + + await tester.pumpWidget( + Directionality( + textDirection: direction, + child: StatefulBuilder( + builder: (context, setState) { + final vsync = tester; + primaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + secondaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + + return SlideCupertino( + animation: primaryController, + secondaryAnimation: secondaryController, + hasShadow: hasShadow, + child: Text('SlideCupertino shadow=$hasShadow dir=${direction.name}'), + ); + }, + ), + ), + ); + + expect( + find.text('SlideCupertino shadow=$hasShadow dir=${direction.name}'), + findsOneWidget, + ); + + primaryController.value = 0.0; + await tester.pump(); + + primaryController.value = 0.5; + await tester.pump(); + + primaryController.value = 1.0; + secondaryController.value = 0.5; + await tester.pump(); + + primaryController.dispose(); + secondaryController.dispose(); + } + } + }); + + testWidgets('Scale renders scale and fade', ( + tester, + ) async { + late AnimationController primaryController; + late AnimationController secondaryController; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (context, setState) { + final vsync = tester; + primaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 250), + ); + secondaryController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 250), + ); + + return Scale( + animation: primaryController, + secondaryAnimation: secondaryController, + initialScale: 0.85, + child: const Text('Scale Content'), + ); + }, + ), + ), + ); + + expect(find.text('Scale Content'), findsOneWidget); + + primaryController.value = 0.0; + await tester.pump(); + + primaryController.value = 1.0; + secondaryController.value = 0.5; + await tester.pump(); + expect(find.text('Scale Content'), findsOneWidget); + + primaryController.dispose(); + secondaryController.dispose(); + }); + }); + + group('Modern BuildTransition Classes and RouteTransition Enum Tests', () { + testWidgets('BuildTransition implementations construct valid widgets', ( + tester, + ) async { + const dummyAnim = kAlwaysCompleteAnimation; + const dummySecAnim = kAlwaysDismissedAnimation; + const child = Text('Inner', textDirection: TextDirection.ltr); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + return Column( + children: [ + const FadeThroughBuildTransition().call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const FadeScaleBuildTransition().call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const SharedAxisBuildTransition( + direction: SharedAxisDirection.horizontal, + ).call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const SharedAxisBuildTransition( + direction: SharedAxisDirection.vertical, + ).call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const SharedAxisBuildTransition( + direction: SharedAxisDirection.scaled, + ).call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const SlideCupertinoBuildTransition().call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const SlideCupertinoBuildTransition(hasShadow: false).call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + const ScaleBuildTransition().call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + CustomBuildTransition( + ({ + required animation, + required secondaryAnimation, + required child, + }) => + Opacity( + opacity: 0.5, + child: child, + ), + ).call( + animation: dummyAnim, + secondaryAnimation: dummySecAnim, + child: child, + ), + ], + ); + }, + ), + ), + ); + + expect(find.text('Inner'), findsNWidgets(9)); + }); + + test('RouteTransition enum contains all modern transitions and correctly maps to BuildTransitions', () { + expect(RouteTransition.fade_through.build, isA()); + expect(RouteTransition.fade_scale.build, isA()); + expect(RouteTransition.shared_axis_x.build, isA()); + expect(RouteTransition.shared_axis_y.build, isA()); + expect(RouteTransition.shared_axis_z.build, isA()); + expect(RouteTransition.slide_cupertino.build, isA()); + expect(RouteTransition.scale.build, isA()); + + // Legacy transitions are preserved + expect(RouteTransition.fade.build, isA()); + expect(RouteTransition.slide_right.build, isA()); + expect(RouteTransition.slide_up.build, isA()); + expect(RouteTransition.slide_left.build, isA()); + expect(RouteTransition.slide_down.build, isA()); + expect(RouteTransition.none.build, isA()); + expect(RouteTransition.only_hero.build, isA()); + }); + }); +} diff --git a/test/overlay_manager_test.dart b/test/overlay_manager_test.dart index c87c4b7..3c8c36f 100644 --- a/test/overlay_manager_test.dart +++ b/test/overlay_manager_test.dart @@ -196,5 +196,124 @@ void main() { expect(find.text('Overlay'), findsNothing); }); + + testWidgets('enqueueBanner displays banner and allows clicks and interaction on underlying widgets', ( + tester, + ) async { + var underlyingButtonClicked = false; + + await tester.pumpWidget( + MaterialApp( + home: OverlayManager( + child: Scaffold( + body: Builder( + builder: (context) { + return Column( + children: [ + ElevatedButton( + onPressed: () { + OverlayManager.enqueueBanner( + context, + content: (remove) => const ColoredBox( + color: Colors.amber, + child: Text('Top Banner Content'), + ), + ); + }, + child: const Text('Show Banner'), + ), + const SizedBox(height: 100), + ElevatedButton( + onPressed: () { + underlyingButtonClicked = true; + }, + child: const Text('Underlying Button'), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + // Trigger banner + await tester.tap(find.text('Show Banner')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Top Banner Content'), findsOneWidget); + + // Click underlying button while banner is visible + await tester.tap(find.text('Underlying Button')); + await tester.pump(); + + expect(underlyingButtonClicked, isTrue); + + // Settle timer + await tester.pump(const Duration(seconds: 6)); + await tester.pump(const Duration(milliseconds: 350)); + }); + + testWidgets('enqueueBanner with standard Flutter buttons works out of the box without special configuration', ( + tester, + ) async { + var bannerActionClicked = false; + + await tester.pumpWidget( + MaterialApp( + home: OverlayManager( + child: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + OverlayManager.enqueueBanner( + context, + duration: Duration.zero, + content: (remove) => Container( + color: Colors.blue, + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Text('Notification'), + ElevatedButton( + onPressed: () { + bannerActionClicked = true; + remove(); + }, + child: const Text('Action'), + ), + ], + ), + ), + ); + }, + child: const Text('Show Persistent Banner'), + ); + }, + ), + ), + ), + ), + ); + + // Open banner + await tester.tap(find.text('Show Persistent Banner')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Notification'), findsOneWidget); + expect(find.text('Action'), findsOneWidget); + + // Tap standard button in banner + await tester.tap(find.text('Action')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(bannerActionClicked, isTrue); + expect(find.text('Notification'), findsNothing); + }); }); } diff --git a/test/sheet_test.dart b/test/sheet_test.dart index 86bfed1..38d8956 100644 --- a/test/sheet_test.dart +++ b/test/sheet_test.dart @@ -280,5 +280,49 @@ void main() { expect(headerWithSnap.maxExtent, equals(57)); expect(headerWithSnap.minExtent, equals(57)); }); + + test('ModalDraggableScrollableSheetParams named constructors instantiate with correct defaults', () { + const small = ModalDraggableScrollableSheetParams.small( + snapSizes: [0.5], + snapAnimationDuration: Duration(milliseconds: 200), + shouldCloseOnMinExtent: false, + expand: false, + snap: true, + withChildSize: 0.8, + ); + expect(small.initialHeightChildSize, equals(0.35)); + expect(small.minHeightChildSize, equals(0.35)); + expect(small.maxHeightChildSize, equals(0.75)); + expect(small.snapSizes, equals([0.5])); + expect(small.snapAnimationDuration, equals(const Duration(milliseconds: 200))); + expect(small.shouldCloseOnMinExtent, isFalse); + expect(small.expand, isFalse); + expect(small.snap, isTrue); + expect(small.withChildSize, equals(0.8)); + + const medium = ModalDraggableScrollableSheetParams.medium( + snapSizes: [0.5], + snapAnimationDuration: Duration(milliseconds: 200), + shouldCloseOnMinExtent: false, + expand: false, + snap: true, + withChildSize: 0.8, + ); + expect(medium.initialHeightChildSize, equals(0.4)); + expect(medium.minHeightChildSize, equals(0.4)); + expect(medium.maxHeightChildSize, equals(0.6)); + + const large = ModalDraggableScrollableSheetParams.large( + snapSizes: [0.5], + snapAnimationDuration: Duration(milliseconds: 200), + shouldCloseOnMinExtent: false, + expand: false, + snap: true, + withChildSize: 0.8, + ); + expect(large.initialHeightChildSize, equals(0.45)); + expect(large.minHeightChildSize, equals(0.3)); + expect(large.maxHeightChildSize, equals(0.86)); + }); }); } From 9bb50ba6608e36744bef100a47266b9c585b9735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Mart=C3=ADnez=20Catal=C3=A1?= <3d24rd0@gmail.com> Date: Wed, 23 Sep 2026 14:06:12 +0200 Subject: [PATCH 2/2] ci: add GitHub Actions workflow to run analyze and test suite on PRs and push --- .github/workflows/ci.yml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2563f00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + name: Analyze & Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies (browser_router) + run: flutter pub get + + - name: Analyze package + run: flutter analyze --fatal-infos --fatal-warnings + + - name: Run unit & widget tests with coverage + run: flutter test --coverage + + - name: Analyze example + working-directory: example + run: | + flutter pub get + flutter analyze --fatal-infos --fatal-warnings