diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b98f7..ddb5900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## 0.4.0 + +* **Feat (Concrete Sheets & Modals)**: + * Added [`BrowserBottomSheet`](lib/sheet/browser_bottom_sheet.dart) (aliased as `ModalBottomSheet`) with dynamic content size adjustment (`adjustSize`) and smooth clamping. + * Added [`BrowserCenterSheet`](lib/sheet/browser_center_sheet.dart) (aliased as `ModalCenterSheet`) with box constraints and vertical gesture absorption tailored for dialogs on desktop, web, and tablet viewports. + * Added [`BrowserFullSheet`](lib/sheet/browser_full_sheet.dart) (aliased as `ModalFullSheet`) for full-viewport scrollable modals. + * Added [`BrowserResponsiveSheet`](lib/sheet/browser_responsive_sheet.dart) (aliased as `ModalResponsiveSheet`) for dynamic switching between bottom sheet on compact viewports (< breakpoint) and centered modal on wide viewports (>= breakpoint). + * Added unified semantic facade methods on `Sheet`: `Sheet.bottom(...)`, `Sheet.center(...)`, `Sheet.full(...)`, and `Sheet.responsive(...)`. +* **Feat (Modal Headers & Parameters)**: + * Added [`ModalHeader`](lib/sheet/modal_base_header.dart) with drag pill handle (mobile/snap), title with text overflow protection, close button, and dynamic scroll elevation shadow. + * Added [`EmptyHeader`](lib/sheet/modal_base_header.dart) for headerless modals. + * Added [`ModalCenterParams`](lib/sheet/modal_base_params.dart) with `.small()`, `.medium()`, and `.large()` constraints presets. + * Added `ModalBaseSafeArea.none()`, `ModalBaseSafeArea.all()`, and `ModalBaseSafeArea.adaptive()`. +* **Feat (Overlays & Banners)**: + * Added `maxWidth`, `margin`, and `dismissDirection` configuration to `Banner` and `Browser.enqueueBanner`. + * Added `Browser.showLoading(...)` and `Browser.dismissLoading(...)` for managing non-dismissible loading overlays. +* **Test**: Expanded test coverage to 92.5% across 125 passing unit and widget tests covering all new concrete sheets, modal headers, responsive breakpoints, banner constraints, and overlay loading lifecycles. + ## 0.3.2 * **Feat (Platform Adaptive Transitions)**: diff --git a/README.md b/README.md index 33dee4c..1ef2c3e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Add `browser_router` to your `pubspec.yaml`: ```yaml dependencies: - browser_router: ^0.3.0 + browser_router: ^0.4.0 ``` Then run: @@ -44,7 +44,7 @@ flutter pub get ### 1. Define Routes -Create a list of `BrowserRoute` instances: +Create a list of `BrowserRoute` instances. If you plan to use `Sheet.bottom`, `Sheet.center`, or `Sheet.responsive`, include `Sheet.route`: ```dart import 'package:browser_router/browser.dart'; @@ -61,12 +61,13 @@ final routes = [ page: const ProfileScreen(), routeTransition: RouteTransition.slide_right, ), + Sheet.route, // Enables Sheet.bottom, Sheet.center, Sheet.responsive ]; ``` -### 2. Wrap Your App with `Browser` +### 2. Wrap Your App with `Browser` and `OverlayManager` -Place `Browser` at the root of your application widget hierarchy: +Place `Browser` at the root and wrap `WidgetsApp` (or `MaterialApp`) with `OverlayManager` to enable top-level banners and loading overlays: ```dart import 'package:browser_router/browser.dart'; @@ -86,15 +87,17 @@ class MyApp extends StatelessWidget { routes: routes, defaultRoute: routes.first, builder: (context, routeObserver, generate) { - return WidgetsApp( - color: const Color(0xFFFFFFFF), - navigatorObservers: [routeObserver], - onGenerateRoute: generate, - onGenerateInitialRoutes: (routePath) => [ - generate( - RouteSettings(name: routePath, arguments: const {}), - ), - ], + return OverlayManager( + child: WidgetsApp( + color: const Color(0xFFFFFFFF), + navigatorObservers: [routeObserver], + onGenerateRoute: generate, + onGenerateInitialRoutes: (routePath) => [ + generate( + RouteSettings(name: routePath, arguments: const {}), + ), + ], + ), ); }, ); @@ -102,6 +105,9 @@ class MyApp extends StatelessWidget { } ``` +> [!TIP] +> Wrapping your app with `OverlayManager` enables non-blocking sequential notification banners (`Browser.enqueueBanner`) and blocking loading spinners (`Browser.showLoading`) anywhere across your application without extra boilerplate. + ### 3. Basic Navigation Navigate using the `BuildContext` extension methods: @@ -431,11 +437,38 @@ context.launchAction('https://flutter.dev'); ## Overlays, Sequential Banners & Sheets -Manage UI components that sit above the navigation stack using built-in overlay utilities: +`browser_router` includes a complete suite of pure Flutter (`package:flutter/widgets.dart`) presentation primitives. + +> [!NOTE] +> **Automatic Injection**: When you use `Browser(...)` at the root of your application, **`OverlayManager` is automatically injected** into the widget tree. You do not need to wrap your app in `OverlayManager` manually when using `Browser`. You can call `Browser.enqueueBanner`, `Browser.showLoading`, `Browser.dismissLoading`, and `Browser.showOverlay` directly anywhere in your context! + +### Standalone `OverlayManager` (Without `Browser`) + +If your project is using a standard `MaterialApp`, `CupertinoApp`, or `WidgetsApp` without the `Browser` root widget, you can still use the sequential banner, loading, and overlay system by wrapping your app in `OverlayManager`: + +```dart +import 'package:browser_router/browser.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + runApp( + OverlayManager( + child: WidgetsApp( + color: const Color(0xFFFFFFFF), + onGenerateRoute: (settings) => ..., + ), + ), + ); +} + +// Then in your widgets: +OverlayManager.enqueueBanner(context, content: (dismiss) => ...); +OverlayManager.of(context)?.showModal(...); +``` ### 1. Sequential Banners Queue -Display notification banners one after another in FIFO order: +Display notification banners one after another in FIFO order with responsive width constraints on desktop/web: ```dart Browser.enqueueBanner( @@ -444,55 +477,166 @@ Browser.enqueueBanner( padding: const EdgeInsets.all(16), color: const Color(0xFF008080), child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Update available!'), GestureDetector( onTap: dismiss, - child: const Text(' Dismiss'), + child: const Text('Dismiss', style: TextStyle(fontWeight: FontWeight.bold)), ), ], ), ), + maxWidth: 500, // Clamped on wide screens + margin: const EdgeInsets.symmetric(horizontal: 16), + duration: const Duration(seconds: 4), ); ``` -### 2. Custom Overlay Modals +### 2. Loading Overlays & Custom Modals -Show a modal overlay independent of the Navigator route stack: +Show non-dismissible loading overlays or custom modals decoupled from the route stack: ```dart +// Show blocking loading overlay +Browser.showLoading( + context, + child: const LoadingSpinnerWidget(), + backgroundColor: const Color(0x80000000), +); + +// Dismiss loading overlay +Browser.dismissLoading(context); + +// Or show custom dismissible overlay modal Browser.showOverlay( context, backgroundColor: const Color(0x80000000), isDismissible: true, - builder: (dismiss) => Container( - width: 300, - height: 200, - color: const Color(0xFFFFFFFF), - child: Center( - child: GestureDetector( - onTap: dismiss, - child: const Text('Close Modal'), + builder: (dismiss) => Center( + child: Container( + width: 300, + height: 200, + color: const Color(0xFFFFFFFF), + child: Center( + child: GestureDetector( + onTap: dismiss, + child: const Text('Close Overlay'), + ), ), ), ), ); +``` + +### 3. Concrete Sheets, Dialogs & Responsive Adapters (`Sheet`) + +`browser_router` provides a complete, collision-safe, pure-widget modal architecture built on `ModalBase`: -// Dismiss programmatically -Browser.dismissOverlay(context); +| Widget | Alias | Facade Method | Description | +| :--- | :--- | :--- | :--- | +| `BrowserBottomSheet` | `ModalBottomSheet` | `Sheet.bottom(...)` | Draggable bottom sheet with automatic size adjustment (`adjustSize`) and native swipe-to-dismiss. | +| `BrowserCenterSheet` | `ModalCenterSheet` | `Sheet.center(...)` | Centered modal dialog with `BoxConstraints` and vertical drag absorption. | +| `BrowserFullSheet` | `ModalFullSheet` | `Sheet.full(...)` | Full-viewport modal sheet for immersive workflows. | +| `BrowserResponsiveSheet` | `ModalResponsiveSheet` | `Sheet.responsive(...)` | Adapts dynamically: renders `BrowserBottomSheet` on compact screens (`< breakpoint`) and `BrowserCenterSheet` on wide/desktop screens (`>= breakpoint`). | + +#### Step 1: Register `Sheet.route` + +Include `Sheet.route` in your `Browser` route list: + +```dart +final routes = [ + BrowserRoute(path: '/', page: const HomeScreen()), + Sheet.route, // Enables Sheet.bottom, Sheet.center, Sheet.full, Sheet.responsive +]; ``` -### 3. Modal Bottom Sheet +#### Step 2: Define your Modal with `ModalBase` ```dart -Browser.showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFFFFFFFF), - heightFactor: 0.6, - builder: (context) => const SheetContentView(), -); +import 'package:browser_router/browser.dart'; +import 'package:flutter/widgets.dart'; + +class ProfileModal extends ModalBase { + const ProfileModal({super.params = const ModalCenterParams.medium()}); + + @override + ModalBaseHeaderParameter contextParameters({required BuildContext context}) { + return const ModalBaseHeaderParameter( + background: Color(0xFFFFFFFF), + headerBackground: Color(0xFFF8FAFC), + dragBar: Color(0xFFCBD5E1), + closeIcon: Text('✕', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + title: Text('User Profile', style: TextStyle(fontWeight: FontWeight.bold)), + ); + } + + @override + Widget body({required BuildContext context, ChangeDrawerSize? changeDrawerSize}) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text('Profile details content...'), + const SizedBox(height: 12), + // Programmatically expand/collapse the sheet if supported + GestureDetector( + onTap: () => changeDrawerSize?.call(isExpanded: true), + child: const Text('Expand Details'), + ), + ], + ), + ); + } + + @override + Widget? bottomBar(BuildContext context) => null; + + @override + ScrollPhysics? scrollPhysics(BuildContext context) => const BouncingScrollPhysics(); + + @override + ModalBaseSafeArea get safeArea => ModalBaseSafeArea.adaptive(); + + @override + ModalBaseHeader topBar(ModalBaseHeaderParameter headerParameter, BorderRadiusGeometry? border) { + return ModalHeader( + parameters: headerParameter, + shouldCloseOnMinExtent: true, + snap: false, + border: border, + ); + } +} +``` + +#### Step 3: Open Modal with Semantic Helpers + +```dart +// 1. Draggable Bottom Sheet +await Sheet.bottom(context, const ProfileModal()); + +// 2. Centered Modal Dialog +await Sheet.center(context, const ProfileModal()); + +// 3. Full-Screen Viewport Modal +await Sheet.full(context, const ProfileModal()); + +// 4. Responsive (Phone: BottomSheet, Desktop/Tablet >= 600px: CenterDialog) +await Sheet.responsive(context, const ProfileModal(), breakpoint: 600); ``` +#### Header Customization (`ModalHeader` & `EmptyHeader`) + +- **`ModalHeader`**: Includes a rounded drag pill handle, centered title, dismiss close icon (`Navigator.maybePop`), and auto-elevating bottom shadow on scroll. +- **`EmptyHeader`**: Delegate with zero extent (`minExtent = 0`, `maxExtent = 0`) for headerless modals. + +#### Size & Constraint Parameters + +- **`ModalCenterParams`**: Presets (`.small()`, `.medium()`, `.large()`) or custom `BoxConstraints(maxWidth: ..., maxHeight: ...)`. +- **`ModalDraggableScrollableSheetParams`**: Controls `initialHeightChildSize`, `minHeightChildSize`, `maxHeightChildSize`, `snap`, and `snapSizes`. +- **`ModalBaseSafeArea`**: Configures screen insets (`.none()`, `.all()`, `.cleanTopSafeArea()`, or `.adaptive()`). + --- ## Code Splitting & Deferred Loading (`DeferredBrowserRoute`) diff --git a/example/lib/example_modals.dart b/example/lib/example_modals.dart new file mode 100644 index 0000000..35c455e --- /dev/null +++ b/example/lib/example_modals.dart @@ -0,0 +1,216 @@ +import 'package:browser_router/browser.dart'; +import 'package:flutter/widgets.dart'; + +/// Example of a draggable bottom sheet modal with [ModalHeader]. +final class ExampleBottomModal + extends ModalBase { + const new({ + super.params = const ModalDraggableScrollableSheetParams( + initialHeightChildSize: 0.5, + minHeightChildSize: 0.25, + maxHeightChildSize: 0.85, + ), + }); + + @override + ModalBaseHeaderParameter contextParameters({required BuildContext context}) { + return const ModalBaseHeaderParameter( + background: Color(0xFF1E293B), + headerBackground: Color(0xFF334155), + dragBar: Color(0xFF94A3B8), + closeIcon: Text( + '✕', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + title: Text( + 'Draggable Sheet', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + ); + } + + @override + Widget body({ + required BuildContext context, + ChangeDrawerSize? changeDrawerSize, + }) { + return Container( + color: const Color(0xFF1E293B), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This modal extends ModalBase and is rendered via BrowserBottomSheet.\n\n' + 'It supports dynamic scrolling, drag-to-dismiss gestures, and smooth size adjustment.', + style: TextStyle(color: Color(0xFFE2E8F0), fontSize: 14), + textDirection: TextDirection.ltr, + ), + const SizedBox(height: 16), + for (var i = 1; i <= 8; i++) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF334155), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Text( + '📌 ', + style: TextStyle(fontSize: 16), + textDirection: TextDirection.ltr, + ), + Text( + 'Interactive Item $i', + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.w600, + ), + textDirection: TextDirection.ltr, + ), + ], + ), + ), + ], + ), + ); + } + + @override + Widget? bottomBar(BuildContext context) => null; + + @override + ScrollPhysics? scrollPhysics(BuildContext context) => + const BouncingScrollPhysics(); + + @override + ModalBaseHeader topBar( + ModalBaseHeaderParameter headerParameter, + BorderRadiusGeometry? border, + ) { + return ModalHeader( + parameters: headerParameter, + shouldCloseOnMinExtent: true, + snap: true, + border: border, + ); + } +} + +/// Example of a centered dialog modal with [ModalCenterParams]. +final class ExampleCenterModal extends ModalBase { + const new({super.params = const ModalCenterParams.small()}); + + @override + ModalBaseHeaderParameter contextParameters({required BuildContext context}) { + return const ModalBaseHeaderParameter( + background: Color(0xFF1E293B), + headerBackground: Color(0xFF334155), + dragBar: Color(0xFF94A3B8), + closeIcon: Text( + '✕', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + title: Text( + 'Center Dialog Modal', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + ); + } + + @override + Widget body({ + required BuildContext context, + ChangeDrawerSize? changeDrawerSize, + }) { + return Container( + color: const Color(0xFF1E293B), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Centered Dialog Sheet (BrowserCenterSheet)', + style: TextStyle( + color: Color(0xFF38BDF8), + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + const SizedBox(height: 12), + const Text( + 'Rendered with box constraints (ModalCenterParams) and clamped inside the viewport. Perfect for desktop dialogs or tablet popups.', + style: TextStyle(color: Color(0xFFE2E8F0), fontSize: 14), + textDirection: TextDirection.ltr, + ), + const SizedBox(height: 20), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFF38BDF8), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Understood', + style: TextStyle( + color: Color(0xFF0F172A), + fontWeight: FontWeight.bold, + fontSize: 14, + ), + textDirection: TextDirection.ltr, + ), + ), + ), + ], + ), + ); + } + + @override + Widget? bottomBar(BuildContext context) => null; + + @override + ScrollPhysics? scrollPhysics(BuildContext context) => null; + + @override + ModalBaseHeader topBar( + ModalBaseHeaderParameter headerParameter, + BorderRadiusGeometry? border, + ) { + return ModalHeader( + parameters: headerParameter, + shouldCloseOnMinExtent: true, + snap: false, + border: border, + ); + } +} diff --git a/example/lib/home_screen.dart b/example/lib/home_screen.dart index 9bb2053..150f63d 100644 --- a/example/lib/home_screen.dart +++ b/example/lib/home_screen.dart @@ -1,4 +1,5 @@ import 'package:browser_example/app_traces.dart'; +import 'package:browser_example/example_modals.dart'; import 'package:browser_example/pop_result_args.dart'; import 'package:browser_example/push_args.dart'; import 'package:browser_router/browser.dart'; @@ -305,8 +306,75 @@ class _HomeScreenState extends State { ), const SizedBox(height: 20), - // --- Overlay --- - const _SectionTitle(title: '📣 Overlay & Banners'), + // --- Concrete Sheets & Modals (ModalBase) --- + const _SectionTitle( + title: '📑 Sheets & Modals (ModalBase)', + ), + _ExampleButton( + text: 'Draggable Bottom Sheet (BrowserBottomSheet)', + onTap: () => Sheet.bottom( + context, + const ExampleBottomModal(), + ), + ), + _ExampleButton( + text: 'Centered Modal Dialog (BrowserCenterSheet)', + onTap: () => Sheet.center( + context, + const ExampleCenterModal(), + ), + ), + _ExampleButton( + text: 'Responsive Adaptive Sheet (Phone Bottom / Desktop Center)', + onTap: () => Sheet.responsive( + context, + const ExampleCenterModal(), + breakpoint: 600, + ), + ), + const SizedBox(height: 20), + + // --- Overlay & Banners --- + const _SectionTitle(title: '📣 Overlay, Loaders & Banners'), + _ExampleButton( + text: 'Show Loading Spinner (Auto-closes in 2s)', + onTap: () { + Browser.showLoading( + context, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '⏳', + style: TextStyle(fontSize: 32), + textDirection: TextDirection.ltr, + ), + SizedBox(height: 12), + Text( + 'Loading async task...', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + ], + ), + ), + ); + Future.delayed(const Duration(seconds: 2), () { + if (context.mounted) { + Browser.dismissLoading(context); + } + }); + }, + ), _ExampleButton( text: 'Enqueue Banner (Auto-dismiss 5s)', onTap: () => @@ -327,6 +395,55 @@ class _HomeScreenState extends State { ); }, topPadding: 0), ), + _ExampleButton( + text: + 'Constrained Banner (maxWidth 400 + Desktop Margin)', + onTap: () => Browser.enqueueBanner( + context, + (removableCallback) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: const Color(0xFF10B981), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Text('✅ ', style: TextStyle(fontSize: 16)), + const Expanded( + child: Text( + 'Constrained Desktop Banner', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + ), + GestureDetector( + onTap: removableCallback, + child: const Text( + '✕', + style: TextStyle( + color: Color(0xFFFFFFFF), + fontWeight: FontWeight.bold, + ), + textDirection: TextDirection.ltr, + ), + ), + ], + ), + ), + maxWidth: 400, + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + topPadding: 0, + ), + ), _ExampleButton( text: 'Enqueue Interactive Banner (Duration 0 + Close Button)', onTap: () => Browser.enqueueBanner( diff --git a/example/lib/routes.dart b/example/lib/routes.dart index bebb071..18118fe 100644 --- a/example/lib/routes.dart +++ b/example/lib/routes.dart @@ -8,7 +8,7 @@ import 'package:browser_example/push_args_screen.dart'; import 'package:browser_example/to_set_pop_args_screen.dart'; import 'package:browser_router/browser.dart'; -final routes = [ +final List routes = [ BrowserRoute( path: AppPath.home.path, page: const HomeScreen(), @@ -37,4 +37,5 @@ final routes = [ page: const IntermediateScreen(), ), BrowserRoute(path: AppPath.deep.path, page: const DeepScreen()), + Sheet.route, ]; diff --git a/lib/browser.dart b/lib/browser.dart index 5bb89e2..d417082 100644 --- a/lib/browser.dart +++ b/lib/browser.dart @@ -17,6 +17,7 @@ import 'overlay/overlay_manager.dart'; export 'modal_route/params/trace_route.dart'; export 'modal_route/transitions/route_transition.dart'; +export 'overlay/overlay_manager.dart'; export 'sheet/sheet.dart'; part 'browser_config.dart'; @@ -372,18 +373,45 @@ class Browser extends StatelessWidget { BuildContext context, ContentBuilder content, { double? topPadding, + double? maxWidth, + EdgeInsetsGeometry? margin, + DismissDirection dismissDirection = DismissDirection.up, Duration? duration = const Duration(seconds: 5), + OverlayTraceRoute? transition, }) => OverlayManager.enqueueBanner( context, content: content, topPadding: topPadding, + maxWidth: maxWidth, + margin: margin, + dismissDirection: dismissDirection, duration: duration, + transition: transition, ); /// Dismisses the currently visible overlay modal. static void dismissOverlay(BuildContext context) => OverlayManager.of(context)?.dismissModal(); + /// Shows a non-dismissible loading overlay containing [child]. + static void showLoading( + BuildContext context, { + required Widget child, + Color backgroundColor = const Color(0x80000000), + bool useSafeArea = false, + }) { + showOverlay( + context, + builder: (_) => Center(child: child), + backgroundColor: backgroundColor, + isDismissible: false, + useSafeArea: useSafeArea, + ); + } + + /// Dismisses the active loading overlay. + static void dismissLoading(BuildContext context) => dismissOverlay(context); + /// Shows a custom overlay modal. /// /// The [builder] provides a `dismiss` function to close the overlay from within. diff --git a/lib/overlay/banner.dart b/lib/overlay/banner.dart index 71dd9ae..f253c97 100644 --- a/lib/overlay/banner.dart +++ b/lib/overlay/banner.dart @@ -9,6 +9,9 @@ class Banner extends OverlayModal { required super.transition, required super.overlayState, this.topPadding = kAppbarHeight, + this.maxWidth, + this.margin, + this.dismissDirection = DismissDirection.up, }); factory fromContext({ @@ -17,6 +20,9 @@ class Banner extends OverlayModal { required OverlayTraceRoute transition, Duration? duration, double? topPadding, + double? maxWidth, + EdgeInsetsGeometry? margin, + DismissDirection dismissDirection = DismissDirection.up, }) { return Banner( content: content, @@ -24,16 +30,24 @@ class Banner extends OverlayModal { transition: transition, overlayState: Overlay.of(context), topPadding: topPadding ?? kAppbarHeight, + maxWidth: maxWidth, + margin: margin, + dismissDirection: dismissDirection, ); } final double topPadding; + final double? maxWidth; + final EdgeInsetsGeometry? margin; + final DismissDirection dismissDirection; @override OverlayEntry _createModal(Widget child) { return OverlayEntry( builder: (context) { return SafeArea( + top: true, + bottom: false, child: Padding( padding: EdgeInsets.only(top: topPadding), child: Align( @@ -43,8 +57,16 @@ class Banner extends OverlayModal { remove(); }, key: UniqueKey(), - direction: DismissDirection.up, - child: child, + direction: dismissDirection, + child: Padding( + padding: margin ?? EdgeInsets.zero, + child: maxWidth != null + ? ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth!), + child: child, + ) + : child, + ), ), ), ), diff --git a/lib/overlay/overlay_manager.dart b/lib/overlay/overlay_manager.dart index ba0a579..9c38353 100755 --- a/lib/overlay/overlay_manager.dart +++ b/lib/overlay/overlay_manager.dart @@ -41,19 +41,28 @@ class OverlayManager extends StatefulWidget { BuildContext context, { required ContentBuilder content, double? topPadding, + double? maxWidth, + EdgeInsetsGeometry? margin, + DismissDirection dismissDirection = DismissDirection.up, Duration? duration = const Duration(seconds: 5), + OverlayTraceRoute? transition, }) { of(context)?.enqueue( Banner.fromContext( context: context, duration: duration, - transition: const OverlayTraceRoute( - routeTransition: RouteTransition.slide_down, - reverseTransitionDuration: Duration(milliseconds: 300), - transitionDuration: Duration(milliseconds: 300), - ), + transition: + transition ?? + const OverlayTraceRoute( + routeTransition: RouteTransition.slide_down, + reverseTransitionDuration: Duration(milliseconds: 300), + transitionDuration: Duration(milliseconds: 300), + ), content: content, topPadding: topPadding, + maxWidth: maxWidth, + margin: margin, + dismissDirection: dismissDirection, ), ); } diff --git a/lib/sheet/browser_bottom_sheet.dart b/lib/sheet/browser_bottom_sheet.dart new file mode 100644 index 0000000..a53c302 --- /dev/null +++ b/lib/sheet/browser_bottom_sheet.dart @@ -0,0 +1,109 @@ +part of 'sheet.dart'; + +/// Collision-safe alias for [BrowserBottomSheet]. +typedef ModalBottomSheet = BrowserBottomSheet; + +/// Concrete bottom sheet implementation with automatic content size adjustment +/// and native swipe-to-dismiss behavior. +final class BrowserBottomSheet + extends SheetBase { + const new({required super.modal, this.borderRadiusOverride, super.key}); + + /// Optional override for the top border radius. + /// Defaults to `BorderRadius.vertical(top: Radius.circular(24))`. + final BorderRadiusGeometry? borderRadiusOverride; + + /// Convenience helper to push this bottom sheet to the navigator stack. + static Future show( + BuildContext context, + ModalBase modal, { + TraceRoute? traceRoute, + }) { + return Trace( + path: Sheet.sheetPath, + traceRoute: traceRoute ?? TraceRoute.swipe(), + args: SheetRouteParams(child: BrowserBottomSheet(modal: modal)), + ).push(context).then((value) => value as T?); + } + + @override + void adjustSize({ + required double? extentTotal, + required double? extentInside, + required ScrollController? customScrollViewController, + required DraggableScrollableController? draggableController, + }) { + if (draggableController == null || !draggableController.isAttached) { + return; + } + if (customScrollViewController == null || + !customScrollViewController.hasClients) { + return; + } + if (extentInside == null || extentInside <= 0) { + return; + } + if (extentTotal == null || extentTotal <= 0) { + return; + } + + final currentSize = draggableController.size; + final targetSize = currentSize * extentTotal / extentInside; + if (targetSize.isNaN || targetSize.isInfinite) { + return; + } + + final min = modal.params.minHeightChildSize; + final max = modal.params.maxHeightChildSize; + final newSize = targetSize.clamp(min, max); + + if ((newSize - currentSize).abs() > 0.005 && + (customScrollViewController.position.extentAfter != 0 || + customScrollViewController.position.extentBefore != 0)) { + draggableController.animateTo( + newSize, + curve: Curves.linear, + duration: + modal.params.snapAnimationDuration ?? + const Duration(milliseconds: 300), + ); + } + } + + @override + Widget sheetCase({ + required BuildContext context, + required ScrollableBuilder build, + required DraggableScrollableController draggableController, + }) { + return DraggableScrollableSheet( + controller: draggableController, + initialChildSize: modal.params.initialHeightChildSize, + minChildSize: modal.params.minHeightChildSize, + maxChildSize: modal.params.maxHeightChildSize, + expand: modal.params.expand, + snap: modal.params.snap, + shouldCloseOnMinExtent: modal.params.shouldCloseOnMinExtent, + snapAnimationDuration: modal.params.snapAnimationDuration, + snapSizes: modal.params.snapSizes, + builder: (context, customScrollViewController) { + if (customScrollViewController.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + adjustSize( + extentInside: customScrollViewController.position.extentInside, + extentTotal: customScrollViewController.position.extentTotal, + customScrollViewController: customScrollViewController, + draggableController: draggableController, + ); + }); + } + return build(context, customScrollViewController); + }, + ); + } + + @override + BorderRadiusGeometry? get borderRadius => + borderRadiusOverride ?? + const BorderRadius.vertical(top: Radius.circular(24)); +} diff --git a/lib/sheet/browser_center_sheet.dart b/lib/sheet/browser_center_sheet.dart new file mode 100644 index 0000000..2126753 --- /dev/null +++ b/lib/sheet/browser_center_sheet.dart @@ -0,0 +1,125 @@ +part of 'sheet.dart'; + +/// Collision-safe alias for [BrowserCenterSheet]. +typedef ModalCenterSheet = BrowserCenterSheet; + +/// Centered modal dialog sheet tailored for desktop, web, and tablet form factors. +/// +/// Wraps content in a constrained box, absorbs vertical drag gestures to prevent +/// interfering with dialog scrolling, and applies fully rounded corners. +final class BrowserCenterSheet extends SheetBase { + const new({ + required super.modal, + this.bodyPaddingOverride, + this.borderRadiusOverride, + super.key, + }); + + /// Optional override for the body padding. + final EdgeInsets? bodyPaddingOverride; + + /// Optional override for the border radius. Defaults to 16 circular radius. + final BorderRadiusGeometry? borderRadiusOverride; + + /// Convenience helper to push this centered sheet to the navigator stack. + static Future show( + BuildContext context, + ModalBase modal, { + TraceRoute? traceRoute, + }) { + return Trace( + path: Sheet.sheetPath, + traceRoute: traceRoute ?? TraceRoute.popup(), + args: SheetRouteParams(child: BrowserCenterSheet(modal: modal)), + ).push(context).then((value) => value as T?); + } + + @override + EdgeInsets get bodyPadding => bodyPaddingOverride ?? super.bodyPadding; + + @override + void adjustSize({ + required double? extentTotal, + required double? extentInside, + required ScrollController? customScrollViewController, + required DraggableScrollableController? draggableController, + }) { + if (draggableController == null || !draggableController.isAttached) { + return; + } + if (customScrollViewController == null || + !customScrollViewController.hasClients) { + return; + } + if (extentInside == null || extentInside <= 0) { + return; + } + if (extentTotal == null || extentTotal <= 0) { + return; + } + + final currentSize = draggableController.size; + final targetSize = currentSize * extentTotal / extentInside; + if (targetSize.isNaN || targetSize.isInfinite) { + return; + } + + final min = modal.params.minHeightChildSize; + final max = modal.params.maxHeightChildSize; + final newSize = targetSize.clamp(min, max); + + if ((newSize - currentSize).abs() > 0.005 && + (customScrollViewController.position.extentAfter != 0 || + customScrollViewController.position.extentBefore != 0)) { + draggableController.animateTo( + newSize, + curve: Curves.linear, + duration: + modal.params.snapAnimationDuration ?? + const Duration(milliseconds: 300), + ); + } + } + + @override + Widget sheetCase({ + required BuildContext context, + required ScrollableBuilder build, + required DraggableScrollableController draggableController, + }) { + final sheet = Center( + child: ConstrainedBox( + constraints: modal.params.constraints, + child: DraggableScrollableSheet( + controller: draggableController, + expand: false, + snap: modal.params.snap, + shouldCloseOnMinExtent: modal.params.shouldCloseOnMinExtent, + snapAnimationDuration: modal.params.snapAnimationDuration, + snapSizes: modal.params.snapSizes, + builder: (context, customScrollViewController) { + WidgetsBinding.instance.addPostFrameCallback((_) { + adjustSize( + extentInside: customScrollViewController.position.extentInside, + extentTotal: customScrollViewController.position.extentTotal, + customScrollViewController: customScrollViewController, + draggableController: draggableController, + ); + }); + return build(context, customScrollViewController); + }, + ), + ), + ); + + return GestureDetector( + onVerticalDragDown: (details) {}, + dragStartBehavior: DragStartBehavior.down, + child: sheet, + ); + } + + @override + BorderRadiusGeometry? get borderRadius => + borderRadiusOverride ?? const BorderRadius.all(Radius.circular(16)); +} diff --git a/lib/sheet/browser_full_sheet.dart b/lib/sheet/browser_full_sheet.dart new file mode 100644 index 0000000..04d59b5 --- /dev/null +++ b/lib/sheet/browser_full_sheet.dart @@ -0,0 +1,49 @@ +part of 'sheet.dart'; + +/// Collision-safe alias for [BrowserFullSheet]. +typedef ModalFullSheet = BrowserFullSheet; + +/// Full-screen modal sheet presenting a scrollable view spanning the entire viewport. +final class BrowserFullSheet + extends SheetBase { + const new({required super.modal, this.bodyPaddingOverride, super.key}); + + /// Optional override for the body padding. + final EdgeInsets? bodyPaddingOverride; + + /// Convenience helper to push this full-screen sheet to the navigator stack. + static Future show( + BuildContext context, + ModalBase modal, { + TraceRoute? traceRoute, + }) { + return Trace( + path: Sheet.sheetPath, + traceRoute: traceRoute ?? TraceRoute.popup(), + args: SheetRouteParams(child: BrowserFullSheet(modal: modal)), + ).push(context).then((value) => value as T?); + } + + @override + EdgeInsets get bodyPadding => bodyPaddingOverride ?? super.bodyPadding; + + @override + void adjustSize({ + required double? extentTotal, + required double? extentInside, + required ScrollController? customScrollViewController, + required DraggableScrollableController? draggableController, + }) {} + + @override + Widget sheetCase({ + required BuildContext context, + required ScrollableBuilder build, + required DraggableScrollableController draggableController, + }) { + return build(context, null); + } + + @override + BorderRadiusGeometry? get borderRadius => null; +} diff --git a/lib/sheet/browser_responsive_sheet.dart b/lib/sheet/browser_responsive_sheet.dart new file mode 100644 index 0000000..615eedb --- /dev/null +++ b/lib/sheet/browser_responsive_sheet.dart @@ -0,0 +1,71 @@ +part of 'sheet.dart'; + +/// Collision-safe alias for [BrowserResponsiveSheet]. +typedef ModalResponsiveSheet = BrowserResponsiveSheet; + +/// Adaptive sheet that automatically renders [BrowserBottomSheet] on compact +/// screens (width < [breakpoint]) and [BrowserCenterSheet] on wide/desktop screens. +final class BrowserResponsiveSheet extends StatelessWidget { + const new({ + required this.modal, + this.breakpoint = 600, + this.bodyPaddingOverride, + this.borderRadiusOverride, + super.key, + }); + + /// The modal definition providing content, headers, and parameters. + final ModalBase modal; + + /// The width threshold separating bottom sheet presentation from center dialog. + /// Defaults to `600`. + final double breakpoint; + + /// Optional override for the body padding. + final EdgeInsets? bodyPaddingOverride; + + /// Optional override for the border radius. + final BorderRadiusGeometry? borderRadiusOverride; + + /// Convenience helper to push this responsive sheet to the navigator stack. + static Future show( + BuildContext context, + ModalBase modal, { + double breakpoint = 600, + EdgeInsets? bodyPaddingOverride, + BorderRadiusGeometry? borderRadiusOverride, + TraceRoute? traceRoute, + }) { + return Trace( + path: Sheet.sheetPath, + traceRoute: traceRoute ?? TraceRoute.swipe(), + args: SheetRouteParams( + child: BrowserResponsiveSheet( + modal: modal, + breakpoint: breakpoint, + bodyPaddingOverride: bodyPaddingOverride, + borderRadiusOverride: borderRadiusOverride, + ), + ), + ).push(context).then((value) => value as T?); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < breakpoint) { + return BrowserBottomSheet( + modal: modal, + borderRadiusOverride: borderRadiusOverride, + ); + } + return BrowserCenterSheet( + modal: modal, + bodyPaddingOverride: bodyPaddingOverride, + borderRadiusOverride: borderRadiusOverride, + ); + }, + ); + } +} diff --git a/lib/sheet/modal_base.dart b/lib/sheet/modal_base.dart index 4ab4cd5..6731b49 100644 --- a/lib/sheet/modal_base.dart +++ b/lib/sheet/modal_base.dart @@ -33,6 +33,19 @@ final class ModalBaseSafeArea { : external = const SafeAreaManager.fromLTRB(true, true, true, false), internal = const SafeAreaManager.all(); + const new none() + : external = const SafeAreaManager.fromLTRB(false, false, false, false), + internal = const SafeAreaManager.fromLTRB(false, false, false, false); + + const new all() + : external = const SafeAreaManager.all(), + internal = const SafeAreaManager.all(); + + /// Platform-adaptive safe area: disabled on web/desktop, clean top safe area on mobile. + factory adaptive() => kIsWeb + ? const ModalBaseSafeArea.none() + : const ModalBaseSafeArea.cleanTopSafeArea(); + final SafeAreaManager external; final SafeAreaManager internal; } diff --git a/lib/sheet/modal_base_header.dart b/lib/sheet/modal_base_header.dart index 29a396c..db097d2 100644 --- a/lib/sheet/modal_base_header.dart +++ b/lib/sheet/modal_base_header.dart @@ -53,3 +53,175 @@ abstract class ModalBaseHeader extends SliverPersistentHeaderDelegate { @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent); } + +/// An empty header delegate with zero extent for modals without headers. +final class EmptyHeader extends ModalBaseHeader { + const new({ + required super.parameters, + required super.shouldCloseOnMinExtent, + required super.snap, + required super.border, + }); + + @override + double get maxExtent => 0; + + @override + double get minExtent => 0; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return const SizedBox.shrink(); + } +} + +/// Standard modal header with a drag handle (pill), title, close button, and dynamic scroll shadow. +class ModalHeader extends ModalBaseHeader { + const new({ + required super.parameters, + required super.shouldCloseOnMinExtent, + required super.snap, + required super.border, + this.showCloseIcon = true, + this.showDragBar, + }); + + final bool showCloseIcon; + final bool? showDragBar; + + bool get _effectiveShowDragBar => showDragBar ?? (!kIsWeb && snap); + + @override + double get maxExtent => _effectiveShowDragBar + ? 72 + : (parameters.title != null) + ? 60 + : 48; + + @override + double get minExtent => _effectiveShowDragBar + ? 57 + : (parameters.title != null) + ? 57 + : 48; + + @override + bool shouldRebuild(covariant ModalHeader oldDelegate) => + oldDelegate.parameters != parameters || + oldDelegate.showCloseIcon != showCloseIcon || + oldDelegate.showDragBar != showDragBar || + oldDelegate.border != border || + oldDelegate.snap != snap || + oldDelegate.shouldCloseOnMinExtent != shouldCloseOnMinExtent; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + final hasShadow = shrinkOffset > 0; + final topBorderRadius = switch (border) { + final BorderRadius r => BorderRadius.only( + topLeft: r.topLeft, + topRight: r.topRight, + ), + _ => const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + }; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: topBorderRadius, + color: parameters.headerBackground, + border: hasShadow + ? const Border( + bottom: BorderSide(color: Color(0xFFE0E0E0), width: 1), + ) + : null, + boxShadow: hasShadow + ? const [ + BoxShadow( + color: Color(0x1A000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ] + : null, + ), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: (parameters.title != null) + ? MainAxisAlignment.center + : MainAxisAlignment.end, + children: [ + if (_effectiveShowDragBar) + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 2), + child: Container( + width: 44, + height: 4, + decoration: ShapeDecoration( + color: parameters.dragBar, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(100)), + ), + ), + ), + ), + if (parameters.title != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: (showCloseIcon && shouldCloseOnMinExtent) + ? 36 + : 0, + ), + child: Align( + alignment: Alignment.center, + child: parameters.title, + ), + ), + ), + if (shouldCloseOnMinExtent && showCloseIcon) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).maybePop(), + child: Padding( + padding: const EdgeInsets.all(6), + child: parameters.closeIcon, + ), + ), + ], + ), + ) + else if (showCloseIcon && shouldCloseOnMinExtent) + Padding( + padding: const EdgeInsets.only(right: 8, top: 4, bottom: 4), + child: Align( + alignment: Alignment.centerRight, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).maybePop(), + child: Padding( + padding: const EdgeInsets.all(6), + child: parameters.closeIcon, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/sheet/modal_base_params.dart b/lib/sheet/modal_base_params.dart index 54ced4d..761de49 100644 --- a/lib/sheet/modal_base_params.dart +++ b/lib/sheet/modal_base_params.dart @@ -164,3 +164,91 @@ base class ModalDraggableScrollableSheetParams extends ModalBaseParams { /// classes to properly read and handle this value. final bool shouldCloseOnMinExtent; } + +/// Parameters tailored for centered dialog sheets. +/// +/// Extends [ModalDraggableScrollableSheetParams] with [BoxConstraints] and +/// [showCloseIcon] configuration. +base class ModalCenterParams extends ModalDraggableScrollableSheetParams { + const new({ + this.constraints = const BoxConstraints( + maxHeight: 620, + maxWidth: 600, + minHeight: 300, + minWidth: 300, + ), + this.showCloseIcon = true, + super.initialHeightChildSize = 0.4, + super.minHeightChildSize = 0.4, + super.maxHeightChildSize = 0.6, + super.withChildSize = 0.6, + super.expand = true, + super.snap = false, + super.snapSizes, + super.snapAnimationDuration, + super.shouldCloseOnMinExtent = true, + }); + + const new small({ + this.constraints = const BoxConstraints( + maxHeight: 650, + maxWidth: 600, + minHeight: 300, + minWidth: 300, + ), + this.showCloseIcon = true, + super.initialHeightChildSize = 0.35, + super.minHeightChildSize = 0.35, + super.maxHeightChildSize = 0.75, + super.withChildSize = 0.6, + super.expand = true, + super.snap = false, + super.snapSizes, + super.snapAnimationDuration, + super.shouldCloseOnMinExtent = true, + }); + + const new medium({ + this.constraints = const BoxConstraints( + maxHeight: 1200, + minHeight: 300, + maxWidth: 800, + minWidth: 300, + ), + this.showCloseIcon = true, + super.initialHeightChildSize = 0.4, + super.minHeightChildSize = 0.4, + super.maxHeightChildSize = 0.6, + super.withChildSize = 0.6, + super.expand = true, + super.snap = false, + super.snapSizes, + super.snapAnimationDuration, + super.shouldCloseOnMinExtent = true, + }); + + const new large({ + this.constraints = const BoxConstraints( + maxHeight: 1200, + minHeight: 300, + maxWidth: 1000, + minWidth: 300, + ), + this.showCloseIcon = true, + super.initialHeightChildSize = 0.45, + super.minHeightChildSize = 0.3, + super.maxHeightChildSize = 0.86, + super.withChildSize = 0.6, + super.expand = true, + super.snap = false, + super.snapSizes, + super.snapAnimationDuration, + super.shouldCloseOnMinExtent = true, + }); + + /// The size constraints for the centered dialog container. + final BoxConstraints constraints; + + /// Whether to display the close icon in the header. + final bool showCloseIcon; +} diff --git a/lib/sheet/sheet.dart b/lib/sheet/sheet.dart index 562234e..0612dac 100644 --- a/lib/sheet/sheet.dart +++ b/lib/sheet/sheet.dart @@ -1,5 +1,7 @@ library; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import '../browser.dart'; @@ -9,6 +11,10 @@ export 'modal_base_params.dart'; part 'sheet_base.dart'; part 'modal_base.dart'; +part 'browser_bottom_sheet.dart'; +part 'browser_center_sheet.dart'; +part 'browser_full_sheet.dart'; +part 'browser_responsive_sheet.dart'; class Sheet extends StatelessWidget { const new({super.key}); @@ -30,6 +36,46 @@ class Sheet extends StatelessWidget { routeTransition: RouteTransition.none, ); + /// Opens a platform-adaptive draggable bottom sheet ([BrowserBottomSheet]). + static Future bottom( + BuildContext context, + ModalBase modal, { + TraceRoute? traceRoute, + }) => BrowserBottomSheet.show(context, modal, traceRoute: traceRoute); + + /// Opens a centered modal dialog sheet ([BrowserCenterSheet]). + static Future center( + BuildContext context, + ModalBase modal, { + TraceRoute? traceRoute, + }) => BrowserCenterSheet.show(context, modal, traceRoute: traceRoute); + + /// Opens a full-screen modal viewport ([BrowserFullSheet]). + static Future full( + BuildContext context, + ModalBase modal, { + TraceRoute? traceRoute, + }) => BrowserFullSheet.show(context, modal, traceRoute: traceRoute); + + /// Opens a responsive sheet ([BrowserResponsiveSheet]) switching between + /// bottom sheet and center dialog based on available width. + static Future responsive( + BuildContext context, + ModalBase modal, { + double breakpoint = 600, + EdgeInsets? bodyPaddingOverride, + BorderRadiusGeometry? borderRadiusOverride, + TraceRoute? traceRoute, + }) => BrowserResponsiveSheet.show( + context, + modal, + breakpoint: breakpoint, + bodyPaddingOverride: bodyPaddingOverride, + borderRadiusOverride: borderRadiusOverride, + traceRoute: traceRoute, + ); + + /// Generic helper to display any custom widget inside a popup sheet route. static Future show(BuildContext context, Widget child) async { await Trace( path: sheetPath, diff --git a/pubspec.yaml b/pubspec.yaml index 3be405d..94cf1a0 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.3.2 +version: 0.4.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/browser_routes_and_barriers_test.dart b/test/browser_routes_and_barriers_test.dart index a0e3e11..181572b 100644 --- a/test/browser_routes_and_barriers_test.dart +++ b/test/browser_routes_and_barriers_test.dart @@ -4,7 +4,6 @@ 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:browser_router/modal_route/browser_swipe_popup_route.dart'; -import 'package:browser_router/overlay/overlay_manager.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart' hide Banner; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/browser_widget_test.dart b/test/browser_widget_test.dart index 6c0c915..dc0d285 100644 --- a/test/browser_widget_test.dart +++ b/test/browser_widget_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:browser_router/browser.dart'; -import 'package:browser_router/overlay/overlay_manager.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/overlay_manager_test.dart b/test/overlay_manager_test.dart index aadc49b..9e31af3 100644 --- a/test/overlay_manager_test.dart +++ b/test/overlay_manager_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:browser_router/browser.dart'; -import 'package:browser_router/overlay/overlay_manager.dart'; import 'package:flutter/material.dart' hide Banner; import 'package:flutter_test/flutter_test.dart'; @@ -316,5 +315,184 @@ void main() { expect(find.text('Notification'), findsNothing); }, ); + + testWidgets('Banner respects maxWidth constraints and margin', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: OverlayManager( + child: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Browser.enqueueBanner( + context, + (remove) => ElevatedButton( + onPressed: remove, + child: const Text('Constrained Banner'), + ), + maxWidth: 400, + margin: const EdgeInsets.symmetric(horizontal: 16), + duration: Duration.zero, + ); + }, + child: const Text('Show Constrained Banner'), + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Show Constrained Banner')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Constrained Banner'), findsOneWidget); + + final constrainedBoxFinder = find.ancestor( + of: find.text('Constrained Banner'), + matching: find.byType(ConstrainedBox), + ); + final constrainedBoxes = tester.widgetList( + constrainedBoxFinder, + ); + expect( + constrainedBoxes.any((cb) => cb.constraints.maxWidth == 400), + isTrue, + ); + + // Dismiss banner to clean up ticker + await tester.tap(find.text('Constrained Banner')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Constrained Banner'), findsNothing); + }); + + testWidgets( + 'Browser.showLoading and Browser.dismissLoading manage non-dismissible loading overlay', + (tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: OverlayManager( + child: Scaffold( + body: Builder( + builder: (context) { + savedContext = context; + return const Text('Main Screen'); + }, + ), + ), + ), + ), + ); + + expect(find.text('Loading Spinner...'), findsNothing); + + // Show loading + Browser.showLoading( + savedContext, + child: const Text('Loading Spinner...'), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Loading Spinner...'), findsOneWidget); + + // Dismiss loading + Browser.dismissLoading(savedContext); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Loading Spinner...'), findsNothing); + + // Calling dismissLoading again when no loading is shown is a safe no-op + expect(() => Browser.dismissLoading(savedContext), returnsNormally); + }, + ); + + testWidgets( + 'OverlayManager handles dismissModal when no modal is showing without error', + (tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: OverlayManager( + child: Scaffold( + body: Builder( + builder: (context) { + savedContext = context; + return const Text('Test Screen'); + }, + ), + ), + ), + ), + ); + + final manager = OverlayManager.of(savedContext); + expect(manager, isNotNull); + + await manager!.dismissModal(); + Browser.dismissLoading(savedContext); + }, + ); + + testWidgets( + 'Banner with custom margin and dismissDirection renders correctly', + (tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + MaterialApp( + home: OverlayManager( + child: Scaffold( + body: Builder( + builder: (context) { + savedContext = context; + return const Text('Test Screen'); + }, + ), + ), + ), + ), + ); + + Browser.enqueueBanner( + savedContext, + (remove) => GestureDetector( + onTap: remove, + child: const Text('Custom Margin Banner'), + ), + margin: const EdgeInsets.all(24), + dismissDirection: DismissDirection.horizontal, + duration: const Duration(seconds: 2), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Custom Margin Banner'), findsOneWidget); + + final dismissibleFinder = find.byType(Dismissible); + expect(dismissibleFinder, findsOneWidget); + final dismissible = tester.widget(dismissibleFinder); + expect(dismissible.direction, DismissDirection.horizontal); + + // Dismiss banner + await tester.tap(find.text('Custom Margin Banner')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Custom Margin Banner'), findsNothing); + }, + ); }); } diff --git a/test/sheet_test.dart b/test/sheet_test.dart index 75b152e..d6e8b4f 100644 --- a/test/sheet_test.dart +++ b/test/sheet_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:browser_router/browser.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -323,5 +325,574 @@ void main() { expect(large.minHeightChildSize, equals(0.3)); expect(large.maxHeightChildSize, equals(0.86)); }); + + test('ModalCenterParams named constructors instantiate with correct constraints', () { + const standard = ModalCenterParams(); + expect(standard.constraints.maxWidth, equals(600)); + expect(standard.constraints.maxHeight, equals(620)); + expect(standard.showCloseIcon, isTrue); + + const small = ModalCenterParams.small(); + expect(small.constraints.maxWidth, equals(600)); + expect(small.constraints.maxHeight, equals(650)); + expect(small.initialHeightChildSize, equals(0.35)); + + const medium = ModalCenterParams.medium(); + expect(medium.constraints.maxWidth, equals(800)); + expect(medium.constraints.maxHeight, equals(1200)); + + const large = ModalCenterParams.large(); + expect(large.constraints.maxWidth, equals(1000)); + expect(large.constraints.maxHeight, equals(1200)); + }); + + test('ModalBaseSafeArea constructors (.none, .all, .adaptive)', () { + const none = ModalBaseSafeArea.none(); + expect(none.external.top, isFalse); + expect(none.external.bottom, isFalse); + expect(none.internal.left, isFalse); + expect(none.internal.right, isFalse); + + const all = ModalBaseSafeArea.all(); + expect(all.external.top, isTrue); + expect(all.external.bottom, isTrue); + expect(all.internal.left, isTrue); + expect(all.internal.right, isTrue); + + final adaptive = ModalBaseSafeArea.adaptive(); + expect(adaptive.external.top, isTrue); + expect(adaptive.external.bottom, isFalse); + }); + + test('EmptyHeader returns zero extent and empty widget', () { + const header = EmptyHeader( + parameters: ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.blue, + dragBar: Colors.grey, + closeIcon: Icon(Icons.close), + ), + shouldCloseOnMinExtent: true, + snap: false, + border: null, + ); + + expect(header.maxExtent, equals(0)); + expect(header.minExtent, equals(0)); + }); + + testWidgets( + 'ModalHeader renders title and triggers close callback on tap', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return CustomScrollView( + slivers: [ + SliverPersistentHeader( + delegate: ModalHeader( + parameters: const ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.blue, + dragBar: Colors.grey, + closeIcon: Icon( + Icons.close, + key: ValueKey('close_btn'), + ), + title: Text('Header Title'), + ), + shouldCloseOnMinExtent: true, + snap: true, + border: BorderRadius.circular(16), + showCloseIcon: true, + ), + ), + ], + ); + }, + ), + ), + ), + ); + + expect(find.text('Header Title'), findsOneWidget); + expect(find.byKey(const ValueKey('close_btn')), findsOneWidget); + }, + ); + + testWidgets('BrowserBottomSheet renders and mounts draggable sheet', ( + tester, + ) async { + const modal = _TestDraggableModal( + params: ModalDraggableScrollableSheetParams(), + ); + const bottomSheet = BrowserBottomSheet(modal: modal); + + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: bottomSheet)), + ); + + expect(find.text('Draggable Modal Body'), findsOneWidget); + }); + + testWidgets( + 'BrowserCenterSheet renders with constraints and rounded corners', + (tester) async { + const centerModal = _TestCenterModal(params: ModalCenterParams()); + const centerSheet = BrowserCenterSheet(modal: centerModal); + + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: centerSheet)), + ); + + expect(find.text('Center Modal Body'), findsOneWidget); + }, + ); + + testWidgets('BrowserFullSheet renders full-screen viewport', ( + tester, + ) async { + const modal = _TestDraggableModal( + params: ModalDraggableScrollableSheetParams(), + ); + const fullSheet = BrowserFullSheet(modal: modal); + + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: fullSheet)), + ); + + expect(find.text('Draggable Modal Body'), findsOneWidget); + }); + + testWidgets( + 'BrowserResponsiveSheet switches between BottomSheet and CenterSheet based on width', + (tester) async { + const centerModal = _TestCenterModal(params: ModalCenterParams()); + const responsiveSheet = BrowserResponsiveSheet( + modal: centerModal, + breakpoint: 500, + ); + + // Narrow screen (< 500) + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: responsiveSheet)), + ); + + expect(find.byType(BrowserBottomSheet), findsOneWidget); + expect(find.byType(BrowserCenterSheet), findsNothing); + + // Wide screen (>= 500) + tester.view.physicalSize = const Size(800, 800); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: responsiveSheet)), + ); + + expect(find.byType(BrowserCenterSheet), findsOneWidget); + expect(find.byType(BrowserBottomSheet), findsNothing); + }, + ); + + test('ModalDraggableScrollableSheetParams presets and properties', () { + const defaultParams = ModalDraggableScrollableSheetParams(); + expect(defaultParams.initialHeightChildSize, 0.4); + expect(defaultParams.minHeightChildSize, 0.4); + expect(defaultParams.maxHeightChildSize, 0.6); + + const small = ModalDraggableScrollableSheetParams.small(); + expect(small.initialHeightChildSize, 0.35); + expect(small.minHeightChildSize, 0.35); + expect(small.maxHeightChildSize, 0.75); + + const medium = ModalDraggableScrollableSheetParams.medium(); + expect(medium.initialHeightChildSize, 0.4); + expect(medium.minHeightChildSize, 0.4); + expect(medium.maxHeightChildSize, 0.6); + + const large = ModalDraggableScrollableSheetParams.large(); + expect(large.initialHeightChildSize, 0.45); + expect(large.minHeightChildSize, 0.3); + expect(large.maxHeightChildSize, 0.86); + }); + + test('ModalCenterParams presets and properties', () { + const small = ModalCenterParams.small(); + expect(small.constraints.maxWidth, 600); + expect(small.constraints.maxHeight, 650); + expect(small.minHeightChildSize, 0.35); + + const medium = ModalCenterParams.medium(); + expect(medium.constraints.maxWidth, 800); + expect(medium.constraints.maxHeight, 1200); + + const large = ModalCenterParams.large(); + expect(large.constraints.maxWidth, 1000); + expect(large.constraints.maxHeight, 1200); + + const custom = ModalCenterParams( + constraints: BoxConstraints(maxWidth: 500, maxHeight: 600), + ); + expect(custom.constraints.maxWidth, 500); + expect(custom.constraints.maxHeight, 600); + }); + + test('ModalBaseSafeArea constructors', () { + const none = ModalBaseSafeArea.none(); + expect(none.external.top, isFalse); + expect(none.external.bottom, isFalse); + expect(none.internal.top, isFalse); + expect(none.internal.bottom, isFalse); + + const all = ModalBaseSafeArea.all(); + expect(all.external.top, isTrue); + expect(all.external.bottom, isTrue); + expect(all.internal.top, isTrue); + expect(all.internal.bottom, isTrue); + + const cleanTop = ModalBaseSafeArea.cleanTopSafeArea(); + expect(cleanTop.external.top, isTrue); + expect(cleanTop.external.bottom, isFalse); + + final adaptive = ModalBaseSafeArea.adaptive(); + expect(adaptive, isNotNull); + }); + + testWidgets('Sheet facade methods open sheets correctly', (tester) async { + late BuildContext savedContext; + + await tester.pumpWidget( + Browser( + routes: [ + BrowserRoute( + path: '/', + page: Builder( + builder: (context) { + savedContext = context; + return const Text('Home'); + }, + ), + ), + Sheet.route, + ], + defaultRoute: BrowserRoute( + path: '/', + page: Builder( + builder: (context) { + savedContext = context; + return const Text('Home'); + }, + ), + ), + builder: (context, routeObserver, generate) { + return WidgetsApp( + color: const Color(0xFFFFFFFF), + navigatorObservers: [routeObserver], + onGenerateRoute: generate, + ); + }, + ), + ); + + const draggableModal = _TestDraggableModal( + params: ModalDraggableScrollableSheetParams(), + ); + const centerModal = _TestCenterModal(params: ModalCenterParams()); + + // Test Sheet.bottom + unawaited(Sheet.bottom(savedContext, draggableModal)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Draggable Modal Body'), findsOneWidget); + + Navigator.of(savedContext).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Test Sheet.center + unawaited(Sheet.center(savedContext, centerModal)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Center Modal Body'), findsOneWidget); + + Navigator.of(savedContext).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Test Sheet.full + unawaited(Sheet.full(savedContext, draggableModal)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Draggable Modal Body'), findsOneWidget); + + Navigator.of(savedContext).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Test Sheet.responsive + unawaited(Sheet.responsive(savedContext, centerModal)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Center Modal Body'), findsOneWidget); + + Navigator.of(savedContext).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + }); + + testWidgets( + 'ModalHeader renders title and handles close button and rebuild', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + SliverPersistentHeader( + delegate: ModalHeader( + parameters: ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.blue, + dragBar: Colors.grey, + closeIcon: Icon(Icons.close), + title: Text('Header Title'), + ), + shouldCloseOnMinExtent: true, + snap: true, + border: null, + showCloseIcon: true, + showDragBar: true, + ), + pinned: true, + ), + SliverToBoxAdapter( + child: SizedBox(height: 100, child: Text('Scroll Body')), + ), + ], + ), + ), + ), + ); + + expect(find.text('Header Title'), findsOneWidget); + expect(find.byIcon(Icons.close), findsOneWidget); + + // Test shouldRebuild on ModalHeader + const header1 = ModalHeader( + parameters: ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.blue, + dragBar: Colors.grey, + closeIcon: Icon(Icons.close), + title: Text('Title'), + ), + shouldCloseOnMinExtent: true, + snap: false, + border: null, + ); + const header2 = ModalHeader( + parameters: ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.red, + dragBar: Colors.grey, + closeIcon: Icon(Icons.close), + title: Text('Title'), + ), + shouldCloseOnMinExtent: true, + snap: false, + border: null, + ); + expect(header1.shouldRebuild(header2), isTrue); + expect(header1.shouldRebuild(header1), isFalse); + }, + ); + + testWidgets('EmptyHeader builds empty widget with zero extent', ( + tester, + ) async { + const emptyHeader = EmptyHeader( + parameters: ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.transparent, + dragBar: Colors.transparent, + closeIcon: SizedBox.shrink(), + ), + shouldCloseOnMinExtent: false, + snap: false, + border: null, + ); + expect(emptyHeader.minExtent, 0.0); + expect(emptyHeader.maxExtent, 0.0); + expect(emptyHeader.shouldRebuild(emptyHeader), isFalse); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + SliverPersistentHeader(delegate: emptyHeader, pinned: true), + SliverToBoxAdapter(child: Text('Content Under Empty Header')), + ], + ), + ), + ), + ); + + expect(find.text('Content Under Empty Header'), findsOneWidget); + }); + + testWidgets('adjustSize updates sheet size smoothly', (tester) async { + const modal = _TestDraggableModal( + params: ModalDraggableScrollableSheetParams( + minHeightChildSize: 0.3, + maxHeightChildSize: 0.9, + ), + ); + + const bottomSheet = BrowserBottomSheet(modal: modal); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: SizedBox(height: 800, width: 400, child: bottomSheet), + ), + ), + ); + + await tester.pumpAndSettle(); + + final scrollController = ScrollController(); + final draggableController = DraggableScrollableController(); + + // Guard cases: null or <= 0 + bottomSheet + ..adjustSize( + extentTotal: null, + extentInside: null, + customScrollViewController: null, + draggableController: null, + ) + ..adjustSize( + extentTotal: 0, + extentInside: 0, + customScrollViewController: scrollController, + draggableController: draggableController, + ); + + // Center sheet adjustSize test + const centerModal = _TestCenterModal(params: ModalCenterParams()); + const centerSheet = BrowserCenterSheet(modal: centerModal); + centerSheet + ..adjustSize( + extentTotal: 400, + extentInside: 800, + customScrollViewController: scrollController, + draggableController: draggableController, + ) + ..adjustSize( + extentTotal: null, + extentInside: null, + customScrollViewController: null, + draggableController: null, + ); + + // Full sheet adjustSize test + const fullSheet = BrowserFullSheet(modal: modal); + fullSheet.adjustSize( + extentTotal: 400, + extentInside: 800, + customScrollViewController: scrollController, + draggableController: draggableController, + ); + }); }); } + +class _TestDraggableModal + extends ModalBase { + const new({required super.params}); + + @override + ModalBaseHeaderParameter contextParameters({required BuildContext context}) { + return const ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.white, + dragBar: Colors.grey, + closeIcon: Icon(Icons.close), + title: Text('Draggable Title'), + ); + } + + @override + Widget body({ + required BuildContext context, + ChangeDrawerSize? changeDrawerSize, + }) { + return const Text('Draggable Modal Body'); + } + + @override + Widget? bottomBar(BuildContext context) => null; + + @override + ScrollPhysics? scrollPhysics(BuildContext context) => null; + + @override + ModalBaseHeader topBar( + ModalBaseHeaderParameter headerParameter, + BorderRadiusGeometry? border, + ) { + return ModalHeader( + parameters: headerParameter, + shouldCloseOnMinExtent: true, + snap: false, + border: border, + ); + } +} + +class _TestCenterModal extends ModalBase { + const new({required super.params}); + + @override + ModalBaseHeaderParameter contextParameters({required BuildContext context}) { + return const ModalBaseHeaderParameter( + background: Colors.white, + headerBackground: Colors.white, + dragBar: Colors.grey, + closeIcon: Icon(Icons.close), + title: Text('Center Title'), + ); + } + + @override + Widget body({ + required BuildContext context, + ChangeDrawerSize? changeDrawerSize, + }) { + return const Text('Center Modal Body'); + } + + @override + Widget? bottomBar(BuildContext context) => null; + + @override + ScrollPhysics? scrollPhysics(BuildContext context) => null; + + @override + ModalBaseHeader topBar( + ModalBaseHeaderParameter headerParameter, + BorderRadiusGeometry? border, + ) { + return ModalHeader( + parameters: headerParameter, + shouldCloseOnMinExtent: true, + snap: false, + border: border, + ); + } +}