From 229720f49aedabfdd3819c79b0a384783423de6f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 4 Aug 2026 11:49:29 +0200 Subject: [PATCH 01/58] feat(scaffold): add StreamScaffold with floating app-bar / bottom-bar support Composes appBar/body/bottom slots and, when a bar floats, enlarges the body's MediaQuery.padding by the bar's height so standard scrollables and SafeArea auto-inset. Floors the floating StreamBottomNavBar pill margin so it never sits flush when the device reports no bottom inset. Adds a gallery use-case and an inset-injection test suite. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/app/gallery_app.directories.g.dart | 24 + .../components/scaffold/stream_scaffold.dart | 387 ++++++++++++ packages/stream_core_flutter/CHANGELOG.md | 2 +- .../components/scaffold/stream_scaffold.dart | 363 ++++++----- .../toolbar/stream_bottom_nav_bar.dart | 21 +- .../scaffold/stream_scaffold_test.dart | 580 +++++++++++++++--- .../ci/stream_bottom_nav_bar_floating.png | Bin 11530 -> 11172 bytes 7 files changed, 1113 insertions(+), 264 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index 8c61b245..c30344f9 100644 --- a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart +++ b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart @@ -110,6 +110,8 @@ import 'package:design_system_gallery/components/reaction/stream_reaction_picker as _design_system_gallery_components_reaction_stream_reaction_picker; import 'package:design_system_gallery/components/reaction/stream_reactions.dart' as _design_system_gallery_components_reaction_stream_reactions; +import 'package:design_system_gallery/components/scaffold/stream_scaffold.dart' + as _design_system_gallery_components_scaffold_stream_scaffold; import 'package:design_system_gallery/components/sheet/stream_sheet.dart' as _design_system_gallery_components_sheet_stream_sheet; import 'package:design_system_gallery/components/snackbar/stream_snackbar.dart' @@ -1133,6 +1135,28 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Scaffold', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamScaffold', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Drawers', + builder: + _design_system_gallery_components_scaffold_stream_scaffold + .buildStreamScaffoldDrawers, + ), + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_scaffold_stream_scaffold + .buildStreamScaffoldPlayground, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Sheet', children: [ diff --git a/apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart b/apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart new file mode 100644 index 00000000..a493f237 --- /dev/null +++ b/apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart @@ -0,0 +1,387 @@ +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamScaffold, + path: '[Components]/Scaffold', +) +Widget buildStreamScaffoldPlayground(BuildContext context) { + final showAppBar = context.knobs.boolean( + label: 'Show app bar', + initialValue: true, + description: 'Renders a StreamAppBar in the top slot.', + ); + + final showBottom = context.knobs.boolean( + label: 'Show bottom', + initialValue: true, + description: 'Renders a StreamBottomNavBar in the bottom slot.', + ); + + final showDrawer = context.knobs.boolean( + label: 'Enable drawer', + description: 'Adds a navigation drawer, opened from the app-bar leading button.', + ); + + final appBarBehavior = context.knobs.object.dropdown( + label: 'App bar behavior', + options: StreamAppBarBehavior.values, + labelBuilder: (value) => value.name, + initialOption: StreamAppBarBehavior.floating, + description: + 'regular — the body sits below the app bar. ' + 'floating — the body extends behind the translucent app bar; the list ' + 'auto-insets from MediaQuery.padding.top. Scroll to see rows pass behind it.', + ); + + final bottomBarBehavior = context.knobs.object.dropdown( + label: 'Bottom bar behavior', + options: StreamBottomAppBarBehavior.values, + labelBuilder: (value) => value.name, + initialOption: StreamBottomAppBarBehavior.floating, + description: + 'regular — the bottom bar sits below the body. ' + 'floating — the body extends behind the translucent bottom bar; the list ' + 'auto-insets from MediaQuery.padding.bottom.', + ); + + final customBackground = context.knobs.boolean( + label: 'Custom background color', + description: 'Overrides the default StreamColorScheme.backgroundApp.', + ); + + final colorScheme = context.streamColorScheme; + final appBarFloating = appBarBehavior == StreamAppBarBehavior.floating; + final bottomFloating = bottomBarBehavior == StreamBottomAppBarBehavior.floating; + + final scaffold = StreamScaffold( + appBarBehavior: appBarBehavior, + bottomBarBehavior: bottomBarBehavior, + backgroundColor: customBackground ? colorScheme.backgroundSurfaceSubtle : null, + appBar: showAppBar ? _demoAppBar(context, floating: appBarFloating, withDrawerButton: showDrawer) : null, + drawer: showDrawer ? const _ExampleDrawer() : null, + bottom: showBottom ? _DemoBottomNav(floating: bottomFloating) : null, + // A plain ListView with NO `padding` — it auto-insets behind the floating + // bars from the MediaQuery.padding that StreamScaffold injects. Zero wiring. + body: const _ChatList(), + ); + + // A scaffold owns the whole screen — return it directly so it fills the + // Widgetbook canvas rather than sitting in a centered frame. + return scaffold; +} + +// ============================================================================= +// Drawers — exercises every forwarded drawer prop +// ============================================================================= + +@widgetbook.UseCase( + name: 'Drawers', + type: StreamScaffold, + path: '[Components]/Scaffold', +) +Widget buildStreamScaffoldDrawers(BuildContext context) { + final showDrawer = context.knobs.boolean( + label: 'drawer', + initialValue: true, + description: 'Leading side panel.', + ); + final showEndDrawer = context.knobs.boolean( + label: 'endDrawer', + initialValue: true, + description: 'Trailing side panel.', + ); + final enableOpenDrag = context.knobs.boolean( + label: 'drawerEnableOpenDragGesture', + initialValue: true, + description: 'Open the drawer with a start-edge swipe.', + ); + final endEnableOpenDrag = context.knobs.boolean( + label: 'endDrawerEnableOpenDragGesture', + initialValue: true, + description: 'Open the end drawer with a trailing-edge swipe.', + ); + final barrierDismissible = context.knobs.boolean( + label: 'drawerBarrierDismissible', + initialValue: true, + description: 'Tap the scrim to dismiss an open drawer.', + ); + final customScrim = context.knobs.boolean( + label: 'Custom drawerScrimColor', + description: 'Tints the scrim with an accent color instead of the default.', + ); + final edgeDragWidth = context.knobs.double.slider( + label: 'drawerEdgeDragWidth', + initialValue: 20, + max: 160, + description: 'Width (px) of the edge zone that opens the drawer by swipe.', + ); + final dragStartBehavior = context.knobs.object.dropdown( + label: 'drawerDragStartBehavior', + options: DragStartBehavior.values, + labelBuilder: (value) => value.name, + initialOption: DragStartBehavior.start, + description: 'How the open-drag gesture is recognized.', + ); + + final colorScheme = context.streamColorScheme; + + return StreamScaffold( + appBar: StreamAppBar( + leading: showDrawer + ? Builder( + builder: (ctx) => StreamButton.icon( + icon: Icon(ctx.streamIcons.more), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () => Scaffold.of(ctx).openDrawer(), + ), + ) + : null, + title: const Text('Drawers'), + trailing: showEndDrawer + ? Builder( + builder: (ctx) => StreamButton.icon( + icon: Icon(ctx.streamIcons.user), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () => Scaffold.of(ctx).openEndDrawer(), + ), + ) + : null, + ), + drawer: showDrawer ? const _ExampleDrawer(title: 'Navigation (drawer)') : null, + endDrawer: showEndDrawer ? const _ExampleDrawer(title: 'Details (endDrawer)') : null, + onDrawerChanged: (isOpen) => _notify(context, 'drawer ${isOpen ? 'opened' : 'closed'}'), + onEndDrawerChanged: (isOpen) => _notify(context, 'endDrawer ${isOpen ? 'opened' : 'closed'}'), + drawerScrimColor: customScrim ? colorScheme.accentPrimary.withValues(alpha: 0.4) : null, + drawerEdgeDragWidth: edgeDragWidth, + drawerEnableOpenDragGesture: enableOpenDrag, + endDrawerEnableOpenDragGesture: endEnableOpenDrag, + drawerDragStartBehavior: dragStartBehavior, + drawerBarrierDismissible: barrierDismissible, + body: _DrawerTestBody(hasDrawer: showDrawer, hasEndDrawer: showEndDrawer), + ); +} + +/// Shows a short-lived snackbar so the drawer-change callbacks are visible. +void _notify(BuildContext context, String message) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message), duration: const Duration(milliseconds: 900))); +} + +/// Body for the Drawers use-case: instructions plus buttons to open each drawer +/// (so the drawers are reachable even when the open-drag gestures are disabled). +class _DrawerTestBody extends StatelessWidget { + const _DrawerTestBody({required this.hasDrawer, required this.hasEndDrawer}); + + final bool hasDrawer; + final bool hasEndDrawer; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Center( + child: Padding( + padding: EdgeInsets.all(spacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Open a drawer from the app-bar buttons, an edge swipe, or below. ' + 'Open/close fires onDrawerChanged (snackbar).', + textAlign: TextAlign.center, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + ), + SizedBox(height: spacing.lg), + if (hasDrawer) + Builder( + builder: (ctx) => StreamButton( + onPressed: () => Scaffold.of(ctx).openDrawer(), + child: const Text('Open drawer'), + ), + ), + if (hasEndDrawer) ...[ + SizedBox(height: spacing.sm), + Builder( + builder: (ctx) => StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + onPressed: () => Scaffold.of(ctx).openEndDrawer(), + child: const Text('Open end drawer'), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ============================================================================= +// Shared chrome builders +// ============================================================================= + +PreferredSizeWidget _demoAppBar( + BuildContext context, { + required bool floating, + bool withDrawerButton = false, +}) { + return StreamAppBar( + // primary: true (default) so the bar self-insets the status bar / notch. + style: StreamAppBarStyle( + behavior: floating ? StreamAppBarBehavior.floating : StreamAppBarBehavior.regular, + ), + leading: withDrawerButton + ? Builder( + builder: (context) => StreamButton.icon( + icon: Icon(context.streamIcons.more), + style: StreamButtonStyle.secondary, + type: floating ? StreamButtonType.outline : StreamButtonType.ghost, + isFloating: floating, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ) + : null, + title: const Text('Messages'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + isFloating: floating, + onPressed: () {}, + ), + ); +} + +/// A stateful [StreamBottomNavBar] demo for the scaffold's bottom slot. When +/// [floating] it renders as a translucent pill over the content whose measured +/// height becomes the body's bottom inset; otherwise it's a docked bar below the +/// body. +class _DemoBottomNav extends StatefulWidget { + const _DemoBottomNav({required this.floating}); + + final bool floating; + + @override + State<_DemoBottomNav> createState() => _DemoBottomNavState(); +} + +class _DemoBottomNavState extends State<_DemoBottomNav> { + var _index = 0; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + return StreamBottomNavBar( + currentIndex: _index, + onTap: (index) => setState(() => _index = index), + behavior: widget.floating ? StreamBottomNavBarBehavior.floating : StreamBottomNavBarBehavior.regular, + items: [ + StreamBottomNavBarItem( + icon: Icon(icons.messageBubble), + selectedIcon: Icon(icons.messageBubbleFill), + label: 'Chats', + ), + StreamBottomNavBarItem(icon: Icon(icons.thread), selectedIcon: Icon(icons.threadFill), label: 'Threads'), + StreamBottomNavBarItem(icon: Icon(icons.mention), selectedIcon: Icon(icons.mention), label: 'Mentions'), + StreamBottomNavBarItem(icon: Icon(icons.user), selectedIcon: Icon(icons.account), label: 'Profile'), + ], + ); + } +} + +// ============================================================================= +// Body +// ============================================================================= + +/// A realistic channel-list body built from a **plain [ListView]** with no +/// `padding` argument. It auto-insets behind the scaffold's floating bars from +/// the injected `MediaQuery.padding` — no manual wiring — so the rows scroll +/// *behind* the translucent bars and rest clear of them. +class _ChatList extends StatelessWidget { + const _ChatList(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return ListView.builder( + itemCount: 24, + itemBuilder: (context, index) { + final row = _rows[index % _rows.length]; + return StreamListTile( + leading: StreamAvatar(placeholder: (_) => Text(row.initials)), + title: Text(row.name), + subtitle: Text(row.message, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Text( + row.time, + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + onTap: () {}, + ); + }, + ); + } +} + +const _rows = <({String initials, String name, String message, String time})>[ + (initials: 'AK', name: 'Alice Kim', message: 'See you at the standup 👋', time: '9:41'), + (initials: 'BT', name: 'Ben Turner', message: 'Pushed the fix, can you review?', time: '9:12'), + (initials: 'CD', name: 'Carla Diaz', message: 'Lunch today?', time: '8:56'), + (initials: 'DO', name: 'Deni Ortega', message: 'Thanks for the help earlier!', time: 'Yst'), + (initials: 'EM', name: 'Eve Miller', message: 'The designs are ready for handoff', time: 'Yst'), + (initials: 'FN', name: 'Femi Nabil', message: 'Call me when you get a sec', time: 'Mon'), + (initials: 'GR', name: 'Grace Rao', message: 'On my way 🚗', time: 'Mon'), + (initials: 'HS', name: 'Hana Sato', message: 'Shipped it! 🚀', time: 'Sun'), +]; + +// ============================================================================= +// Helpers +// ============================================================================= + +/// A minimal drawer used by the drawer demos. +class _ExampleDrawer extends StatelessWidget { + const _ExampleDrawer({this.title = 'Navigation'}); + + final String title; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Drawer( + backgroundColor: colorScheme.backgroundSurface, + child: SafeArea( + child: Padding( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary)), + SizedBox(height: spacing.md), + Text('Forwarded to the underlying Scaffold.', style: textTheme.bodyDefault), + ], + ), + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 1395a18a..e5a0b935 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -6,7 +6,7 @@ - Added optional `semanticsLabel` to `StreamAvatar`, `StreamAvatarGroup`, and `StreamAvatarStack`. On `StreamAvatar`, `null` (default) drops the placeholder's initials from the semantics tree via `ExcludeSemantics`; a non-null value exposes it as a labeled image node. On `StreamAvatarGroup` / `StreamAvatarStack`, `null` composes through — each child's own `semanticsLabel` applies — while a non-null value collapses the group into a single labeled image node and hides children and the "+N" overflow badge. - Added `StreamColorScheme.fromSeed` — builds a complete light or dark color scheme from a single brand color, optionally with a custom chrome color. When chrome is omitted it is derived from the brand hue at `StreamColorScheme.neutralChroma`. - `StreamColorSwatch.fromColor` now generates shades in the HCT color space instead of HSL. Each shade takes its tone from a fixed ladder measured from the Stream design tokens, so a shade's contrast is predictable regardless of the seed's hue — seeding a light color such as yellow now yields an accent that can carry white text. Two consequences: the seed is no longer reproduced verbatim at shade 500 (it is normalized onto the ladder), and dark scales now mirror the ladder so the seed's tone lands on shade 300, matching the default dark palette. -- Added `StreamScaffold` — a full-page scaffold that supports both regular and floating app-bar / bottom-bar layouts. Injects `StreamScaffoldInsets` into the widget tree so scrollable bodies can read the effective top and bottom padding from floating bars without coupling to layout details. +- Added `StreamScaffold` — a full-page scaffold that supports both regular and floating app-bar / bottom-bar layouts. When a bar floats, it enlarges the body's `MediaQuery.padding` by the bar's extent, so standard scrollables (`ListView` / `GridView`) and `SafeArea` inset their content automatically; scroll views that don't consume `MediaQuery.padding` (e.g. `CustomScrollView`) can read `MediaQuery.paddingOf(context)` explicitly. - Added `StreamBottomNavBar` and `StreamBottomNavBarItem` — a bottom navigation bar with icon, selected-icon, and label slots per item. Renders either a regular docked bar or a floating pill with a gradient fade-out beneath it, resolved from the per-instance `behavior`, the `StreamBottomNavBarTheme`, then `StreamAppStyle`. Follows the standard `Props`/`Default`/`StreamComponentFactory` pattern (`bottomNavBar` builder) and is themeable via `StreamBottomNavBarTheme` / `StreamBottomNavBarStyle` (selected/unselected item colours, icon size, label styles, border, pill radius). Items announce themselves as accessible buttons via `Semantics`. - Added floating-bar support to `StreamAppBar` via a new `appBarBehavior` property. When set to `StreamAppBarBehavior.floating`, the app bar renders above the body with a translucent background and a gradient overlay. - Added `isFloating` to `StreamAvatar`, `StreamAvatarGroup`, and `StreamAvatarStack`. When true, the avatar renders with a Material elevation (`StreamAvatarThemeData.floatingElevation`, defaulting to `StreamElevation.level2`) instead of a hand-painted shadow, so it matches the shadow of any other elevated surface next to it. Setting `isFloating` on a group or stack enables floating for every child avatar at once. diff --git a/packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart b/packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart index 61eac1c9..bae5e54d 100644 --- a/packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart +++ b/packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart @@ -1,3 +1,6 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart'; import '../../theme/components/stream_app_bar_theme.dart'; @@ -5,81 +8,13 @@ import '../../theme/components/stream_bottom_app_bar_theme.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/stream_theme_extensions.dart'; -// --------------------------------------------------------------------------- -// InheritedWidget -// --------------------------------------------------------------------------- - -/// Provides the effective top and bottom padding introduced by -/// [StreamScaffold] to its descendants. -/// -/// When the scaffold's `appBar` is floating, [topPadding] equals the app-bar -/// height plus the system safe-area inset so that scrollable bodies can inset -/// their content below the bar without being clipped. When the `bottom` slot -/// is floating, [bottomPadding] equals the measured height of that widget so -/// content clears it. -/// -/// Read the values with [StreamScaffoldInsets.of] or -/// [StreamScaffoldInsets.maybeOf]: -/// -/// ```dart -/// final insets = StreamScaffoldInsets.of(context); -/// StreamMessageListView( -/// topPadding: insets.topPadding, -/// bottomPadding: insets.bottomPadding, -/// ) -/// ``` -class StreamScaffoldInsets extends InheritedWidget { - /// Creates an insets notification for the given [topPadding] and - /// [bottomPadding]. - const StreamScaffoldInsets({ - super.key, - required this.topPadding, - required this.bottomPadding, - required super.child, - }) : assert(topPadding >= 0, 'topPadding must be non-negative'), - assert(bottomPadding >= 0, 'bottomPadding must be non-negative'); - - /// The vertical space (in logical pixels) occupied by the floating app bar - /// at the top, including the system status-bar inset. - /// - /// `0.0` when the app bar is regular (not floating) or absent. - final double topPadding; - - /// The vertical space (in logical pixels) occupied by the floating bottom - /// widget, including any system home-indicator inset. - /// - /// `0.0` when the bottom widget is regular (not floating) or absent. - final double bottomPadding; - - /// Returns the [StreamScaffoldInsets] from the closest ancestor, asserting - /// that one exists. - static StreamScaffoldInsets of(BuildContext context) { - final result = context.dependOnInheritedWidgetOfExactType(); - assert(result != null, 'No StreamScaffoldInsets found in widget tree'); - return result!; - } - - /// Returns the [StreamScaffoldInsets] from the closest ancestor, or `null` - /// when none is present. - static StreamScaffoldInsets? maybeOf(BuildContext context) => - context.dependOnInheritedWidgetOfExactType(); - - @override - bool updateShouldNotify(StreamScaffoldInsets old) => - topPadding != old.topPadding || bottomPadding != old.bottomPadding; -} - -// --------------------------------------------------------------------------- -// Main widget -// --------------------------------------------------------------------------- - -/// A full-page scaffold for Stream surfaces that supports both regular and -/// floating app-bar / bottom-bar layouts. +/// A scaffold for full-page surfaces in the Stream design system, supporting +/// both regular and floating app-bar / bottom-bar layouts. /// /// [StreamScaffold] composes three slots — [appBar], [body], and [bottom] — -/// and injects an [StreamScaffoldInsets] into the widget tree so that scrollable -/// bodies can respect the visual extents of floating bars without knowing about -/// the layout directly. +/// and enlarges the body's [MediaQuery] padding by the extent of any floating +/// bar so that standard scrollables ([ListView]/[GridView]) and [SafeArea] +/// inset their content automatically, without knowing about the layout. /// /// ## Floating vs. regular /// @@ -92,35 +27,43 @@ class StreamScaffoldInsets extends InheritedWidget { /// [StreamAppStyle.regular]). /// /// * [StreamAppBarBehavior.floating] — the body extends *behind* the app bar; -/// [StreamScaffoldInsets.topPadding] is set to the bar height plus the -/// system status-bar inset so the body can add its own inset. +/// the body's `MediaQuery.padding.top` is set to the app-bar height so content +/// rests clear of the bar. /// * [StreamBottomAppBarBehavior.floating] — the body extends *behind* the bottom -/// widget; [StreamScaffoldInsets.bottomPadding] equals the measured height of -/// that widget. +/// widget; the body's `MediaQuery.padding.bottom` is set to the measured height +/// of that widget. /// * `regular` for either slot — no overlap; the slot occupies its own space /// and the corresponding inset is `0.0`. /// /// ## Drawer support /// -/// [drawer] is forwarded to the underlying [Scaffold] so that widgets in -/// [appBar] (e.g. `StreamChannelListHeader`) can find the drawer via -/// `Scaffold.maybeOf(context)?.openDrawer()`. +/// Provide a [drawer] and/or [endDrawer] to add slide-in side panels; a widget +/// in [appBar] can open them (as `StreamChannelListHeader` does for its menu). +/// +/// ## Reading the insets +/// +/// Standard scrollables auto-inset. Widgets that do not consume +/// `MediaQuery.padding` (e.g. a [CustomScrollView] or a `ScrollablePositionedList`) +/// can read it explicitly and apply it as scroll padding: /// -/// ## InheritedWidget access +/// ```dart +/// final padding = MediaQuery.paddingOf(context); +/// // apply padding.top / padding.bottom as the scroll view's padding … +/// ``` +/// +/// {@tool snippet} /// /// ```dart -/// // Inside body: -/// final insets = StreamScaffoldInsets.of(context); -/// StreamMessageListView( -/// topPadding: insets.topPadding, -/// bottomPadding: insets.bottomPadding, -/// ); +/// StreamScaffold( +/// appBar: StreamAppBar(title: Text('Home')), +/// body: ListView(/* … */), +/// bottom: StreamBottomNavBar(/* … */), +/// ) /// ``` +/// {@end-tool} /// /// See also: /// -/// * [StreamScaffoldInsets], the inherited widget that carries the inset -/// values. /// * [StreamAppBar], the standard floating/regular app bar. /// * [StreamBottomAppBar], the standard toolbar for the bottom slot. class StreamScaffold extends StatelessWidget { @@ -131,44 +74,85 @@ class StreamScaffold extends StatelessWidget { required this.body, this.bottom, this.drawer, + this.onDrawerChanged, this.endDrawer, + this.onEndDrawerChanged, + this.drawerScrimColor, + this.drawerEdgeDragWidth, + this.drawerEnableOpenDragGesture = true, + this.endDrawerEnableOpenDragGesture = true, + this.drawerDragStartBehavior = .start, + this.drawerBarrierDismissible = true, this.appBarBehavior, this.bottomBarBehavior, this.backgroundColor, this.resizeToAvoidBottomInset = true, + this.restorationId, }); /// An optional app bar displayed at the top of the scaffold. /// - /// Must implement [PreferredSizeWidget] so the scaffold can read the height - /// for inset calculations. + /// Must implement [PreferredSizeWidget]. Its measured height becomes the + /// body's top inset when floating. final PreferredSizeWidget? appBar; /// The primary content of the scaffold. /// - /// [StreamScaffoldInsets] is injected into this subtree so descendants can - /// read the effective top and bottom insets. + /// The body's [MediaQuery] padding is enlarged by the extent of any floating + /// bar so standard scrollables inset their content automatically. final Widget body; /// An optional widget displayed at the bottom of the scaffold. /// /// When [bottomBarBehavior] is [StreamBottomAppBarBehavior.floating] this widget - /// overlaps the body; otherwise it sits below it (equivalent to - /// [Scaffold.bottomNavigationBar]). + /// overlaps the body; otherwise it sits below it. final Widget? bottom; /// A panel displayed to the side of the [body], often hidden on mobile /// devices. Swipes in from either [TextDirection.ltr] start side or /// [TextDirection.rtl] start side. - /// - /// Forwarded directly to the underlying [Scaffold]. final Widget? drawer; - /// A panel displayed to the opposite side of the body from the [drawer]. - /// - /// Forwarded directly to the underlying [Scaffold]. + /// Called when the [drawer] changes to open or closed. + final DrawerCallback? onDrawerChanged; + + /// A panel displayed to the opposite side of the [body] from the [drawer]. final Widget? endDrawer; + /// Called when the [endDrawer] changes to open or closed. + final DrawerCallback? onEndDrawerChanged; + + /// The color of the scrim that darkens the [body] while a drawer is open. + /// + /// When null the ambient [DrawerThemeData.scrimColor] is used. + final Color? drawerScrimColor; + + /// The width of the edge area within which a horizontal swipe opens the + /// [drawer]. + /// + /// When null a platform-dependent default is used. + final double? drawerEdgeDragWidth; + + /// Whether the [drawer] can be opened with an edge-swipe gesture. + /// + /// Defaults to `true`. + final bool drawerEnableOpenDragGesture; + + /// Whether the [endDrawer] can be opened with an edge-swipe gesture. + /// + /// Defaults to `true`. + final bool endDrawerEnableOpenDragGesture; + + /// Determines the way a drawer's open-drag gesture is handled. + /// + /// Defaults to [DragStartBehavior.start]. + final DragStartBehavior drawerDragStartBehavior; + + /// Whether tapping the scrim dismisses an open drawer. + /// + /// Defaults to `true`. + final bool drawerBarrierDismissible; + /// Per-instance override for the app-bar floating behaviour. /// /// When null the value is resolved from [StreamAppBarStyle.behavior] @@ -193,86 +177,68 @@ class StreamScaffold extends StatelessWidget { /// Defaults to `true`. final bool resizeToAvoidBottomInset; + /// Restoration ID to save and restore the state of the scaffold. + /// + /// When null the scaffold's internal state (such as an open drawer) is not + /// restored. + final String? restorationId; + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final appStyle = context.streamTheme.appStyle; - final effectiveStreamAppBarBehavior = - appBarBehavior ?? - context.streamAppBarTheme.style?.behavior ?? - (appStyle.isFloating ? StreamAppBarBehavior.floating : StreamAppBarBehavior.regular); - final effectiveStreamBottomAppBarBehavior = - bottomBarBehavior ?? - context.streamBottomAppBarTheme.style?.behavior ?? - (appStyle.isFloating ? StreamBottomAppBarBehavior.floating : StreamBottomAppBarBehavior.regular); - final effectiveBackgroundColor = backgroundColor ?? context.streamColorScheme.backgroundApp; - - final appBarFloating = effectiveStreamAppBarBehavior == StreamAppBarBehavior.floating; - final bottomFloating = effectiveStreamBottomAppBarBehavior == StreamBottomAppBarBehavior.floating && bottom != null; - - final topInset = appBarFloating ? (appBar?.preferredSize.height ?? 0) + MediaQuery.paddingOf(context).top : 0.0; - - // When neither slot is floating, use a plain Scaffold for maximum - // compatibility (e.g. keyboard avoidance, Scaffold.of, etc.). - // The bottom widget lives inside the body Column (not bottomNavigationBar) - // because bottomNavigationBar is not repositioned above the keyboard on - // Android, which causes text-input composers to be hidden behind the IME. - if (!appBarFloating && !bottomFloating) { - return Scaffold( - backgroundColor: effectiveBackgroundColor, - resizeToAvoidBottomInset: resizeToAvoidBottomInset, - appBar: appBar, - drawer: drawer, - endDrawer: endDrawer, - body: Column( - children: [ - Expanded( - child: StreamScaffoldInsets( - topPadding: 0, - bottomPadding: 0, - child: body, - ), - ), - ?bottom, - ], - ), - ); - } + final appBarStyle = context.streamAppBarTheme.style; + final bottomAppBarStyle = context.streamBottomAppBarTheme.style; + + var effectiveAppBarBehavior = appBarBehavior ?? appBarStyle?.behavior; + effectiveAppBarBehavior ??= appStyle.isFloating ? .floating : .regular; + + var effectiveBottomBarBehavior = bottomBarBehavior ?? bottomAppBarStyle?.behavior; + effectiveBottomBarBehavior ??= appStyle.isFloating ? .floating : .regular; + + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundApp; + + final appBarFloating = effectiveAppBarBehavior == .floating; + final bottomFloating = effectiveBottomBarBehavior == .floating && bottom != null; return Scaffold( backgroundColor: effectiveBackgroundColor, resizeToAvoidBottomInset: resizeToAvoidBottomInset, - // The appBar always goes in the Scaffold's standard slot. - // extendBodyBehindAppBar controls whether the body overlaps it (floating) - // or sits below it (regular). Never drop it from the slot. + restorationId: restorationId, + // The appBar always occupies the Scaffold's standard slot; + // extendBodyBehindAppBar controls whether the body overlaps it. appBar: appBar, drawer: drawer, + onDrawerChanged: onDrawerChanged, endDrawer: endDrawer, + onEndDrawerChanged: onEndDrawerChanged, + drawerScrimColor: drawerScrimColor, + drawerEdgeDragWidth: drawerEdgeDragWidth, + drawerEnableOpenDragGesture: drawerEnableOpenDragGesture, + endDrawerEnableOpenDragGesture: endDrawerEnableOpenDragGesture, + drawerDragStartBehavior: drawerDragStartBehavior, + drawerBarrierDismissible: drawerBarrierDismissible, extendBodyBehindAppBar: appBarFloating, extendBody: bottomFloating, - // In regular-bottom mode, slot the bottom into the Scaffold normally. - bottomNavigationBar: bottomFloating ? null : bottom, body: _StreamScaffoldBody( - topInset: topInset, - bottom: bottomFloating ? bottom : null, + floating: bottomFloating, + bottom: bottom, child: body, ), ); } } -// --------------------------------------------------------------------------- -// Custom layout -// --------------------------------------------------------------------------- - -/// Layout slot identifiers used by [_StreamScaffoldBodyDelegate]. +// Layout slot identifiers used by the body delegate. enum _Slot { body, bottom } -/// Custom [BoxConstraints] that carries the measured [bottomHeight] so a -/// [LayoutBuilder] inside the body can read it synchronously within the same -/// layout pass. -/// -/// [==] and [hashCode] are overridden to trigger a child re-layout whenever -/// [bottomHeight] changes, even when the outer size constraints are unchanged. +// Custom BoxConstraints that carries the measured bottomHeight so a +// LayoutBuilder inside the body can read it synchronously within the same +// layout pass. +// +// == and hashCode are overridden to trigger a child re-layout whenever +// bottomHeight changes, even when the outer size constraints are unchanged. class _BodyBoxConstraints extends BoxConstraints { const _BodyBoxConstraints({ super.maxWidth, @@ -280,7 +246,7 @@ class _BodyBoxConstraints extends BoxConstraints { required this.bottomHeight, }) : assert(bottomHeight >= 0, 'bottomHeight must be non-negative'); - /// The measured height of the floating bottom slot. + // The measured height of the floating bottom slot. final double bottomHeight; @override @@ -293,21 +259,14 @@ class _BodyBoxConstraints extends BoxConstraints { int get hashCode => Object.hash(super.hashCode, bottomHeight); } -/// Measures the [bottom] slot first, then gives the [body] the full available -/// size annotated with the bottom height via [_BodyBoxConstraints]. +// Measures the floating bottom, then lays the body out at full size annotated +// with the bottom's height via _BodyBoxConstraints, in a single pass. class _StreamScaffoldBodyDelegate extends MultiChildLayoutDelegate { - _StreamScaffoldBodyDelegate({required this.hasBottom}); - - final bool hasBottom; - @override void performLayout(Size size) { - double bottomHeight = 0; - if (hasBottom) { - final bottomSize = layoutChild(_Slot.bottom, BoxConstraints.loose(size)); - bottomHeight = bottomSize.height; - positionChild(_Slot.bottom, Offset(0, size.height - bottomHeight)); - } + final bottomSize = layoutChild(_Slot.bottom, BoxConstraints.loose(size)); + final bottomHeight = bottomSize.height; + positionChild(_Slot.bottom, Offset(0, size.height - bottomHeight)); layoutChild( _Slot.body, @@ -321,44 +280,78 @@ class _StreamScaffoldBodyDelegate extends MultiChildLayoutDelegate { } @override - bool shouldRelayout(_StreamScaffoldBodyDelegate oldDelegate) => hasBottom != oldDelegate.hasBottom; + bool shouldRelayout(_StreamScaffoldBodyDelegate oldDelegate) => false; } -/// Wraps the user-supplied [body] and an optional floating [bottom] inside a -/// [CustomMultiChildLayout] that publishes the measured bottom height into -/// [StreamScaffoldInsets] in a single layout pass. +// Composes the scaffold body with its optional bottom slot. +// +// * No bottom -> the body fills the region. +// * Regular bottom -> the body sits above it in a Column, never in the +// Scaffold's bottomNavigationBar slot: that slot is not lifted above the +// on-screen keyboard on Android and would hide a text-input composer behind +// the IME. +// * Floating bottom -> the bottom overlaps the body via a CustomMultiChildLayout +// that measures its height and enlarges the body's MediaQuery padding so +// scrollables clear it, in a single layout pass. class _StreamScaffoldBody extends StatelessWidget { const _StreamScaffoldBody({ - required this.topInset, + required this.floating, required this.bottom, required this.child, - }); + }) : assert(!floating || bottom != null, 'A floating body requires a bottom widget.'); - final double topInset; + final bool floating; final Widget? bottom; final Widget child; @override Widget build(BuildContext context) { - final hasBottom = bottom != null; + if (!floating) { + final bottom = this.bottom; + if (bottom == null) return child; + + // The docked bottom sits below the body and owns the bottom safe-area + // inset (it draws over the home indicator). Strip that inset from the body + // so its scrollables rest on the bottom widget instead of reserving space + // for the home indicator a second time. + return Column( + children: [ + Expanded( + child: MediaQuery.removePadding(context: context, removeBottom: true, child: child), + ), + bottom, + ], + ); + } return CustomMultiChildLayout( - delegate: _StreamScaffoldBodyDelegate(hasBottom: hasBottom), + delegate: _StreamScaffoldBodyDelegate(), children: [ LayoutId( id: _Slot.body, child: LayoutBuilder( builder: (context, constraints) { final bottomHeight = constraints is _BodyBoxConstraints ? constraints.bottomHeight : 0.0; - return StreamScaffoldInsets( - topPadding: topInset, - bottomPadding: bottomHeight, + + // Publish the floating bottom bar's height through + // MediaQuery.padding.bottom so standard scrollables (ListView / + // GridView) and SafeArea inset their content automatically. The top + // inset already arrives through MediaQuery.padding.top when the app + // bar floats, so only the bottom is added here. math.max never + // shrinks an existing system inset. + final mediaQuery = MediaQuery.of(context); + final effectivePadding = mediaQuery.padding.copyWith( + bottom: math.max(mediaQuery.padding.bottom, bottomHeight), + ); + + return MediaQuery( + data: mediaQuery.copyWith(padding: effectivePadding), child: child, ); }, ), ), - if (hasBottom) LayoutId(id: _Slot.bottom, child: bottom!), + LayoutId(id: _Slot.bottom, child: bottom!), ], ); } diff --git a/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_nav_bar.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_nav_bar.dart index 57d4b274..a6109b55 100644 --- a/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_nav_bar.dart +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_nav_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; @@ -476,12 +478,10 @@ class _FloatingChrome extends StatelessWidget { final BorderRadiusGeometry borderRadius; final Widget child; - LinearGradient _buildGradient(BuildContext context) { - final safeAreaBottom = MediaQuery.paddingOf(context).bottom; - // Approximate rendered height: safe area + item height. - const itemHeight = kBottomNavigationBarHeight; - final totalHeight = safeAreaBottom + itemHeight; - final solidFraction = totalHeight > 0 ? safeAreaBottom / totalHeight : 0.0; + LinearGradient _buildGradient(double bottomInset) { + // Solid across the bottom inset, fading over the item height into the + // content behind the bar. + final solidFraction = bottomInset / (bottomInset + kBottomNavigationBarHeight); return streamFloatingFadeLinearGradient( color: gradientColor, @@ -493,14 +493,17 @@ class _FloatingChrome extends StatelessWidget { @override Widget build(BuildContext context) { - final spacing = context.streamSpacing; + final margin = context.streamSpacing.xl; + // Floored so the pill never sits flush when the device reports no bottom inset. + final bottomInset = math.max(MediaQuery.paddingOf(context).bottom, margin); return DecoratedBox( - decoration: BoxDecoration(gradient: _buildGradient(context)), + decoration: BoxDecoration(gradient: _buildGradient(bottomInset)), child: SafeArea( top: false, + minimum: EdgeInsets.only(bottom: margin), child: Padding( - padding: EdgeInsets.symmetric(horizontal: spacing.xl), + padding: EdgeInsets.symmetric(horizontal: margin), child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( diff --git a/packages/stream_core_flutter/test/components/scaffold/stream_scaffold_test.dart b/packages/stream_core_flutter/test/components/scaffold/stream_scaffold_test.dart index b569089d..b89cc011 100644 --- a/packages/stream_core_flutter/test/components/scaffold/stream_scaffold_test.dart +++ b/packages/stream_core_flutter/test/components/scaffold/stream_scaffold_test.dart @@ -9,47 +9,89 @@ Widget _withStreamTheme(Widget child, {StreamAppStyle appStyle = StreamAppStyle. ); } -class _InsetsProbe extends StatelessWidget { - const _InsetsProbe(); +// --------------------------------------------------------------------------- +// Harness for the MediaQuery inset-injection tests +// --------------------------------------------------------------------------- + +const double _kBarHeight = 56; + +/// Captures the effective insets seen by the scaffold body during build. +class _CapturedInsets { + EdgeInsets? padding; + EdgeInsets? viewPadding; + EdgeInsets? viewInsets; +} + +class _InsetProbe extends StatelessWidget { + const _InsetProbe(this.captured); + + final _CapturedInsets captured; @override Widget build(BuildContext context) { - final insets = StreamScaffoldInsets.of(context); - return Text('top:${insets.topPadding} bottom:${insets.bottomPadding}'); + final mediaQuery = MediaQuery.of(context); + captured + ..padding = mediaQuery.padding + ..viewPadding = mediaQuery.viewPadding + ..viewInsets = mediaQuery.viewInsets; + + return const SizedBox.expand(); } } -void main() { - group('StreamScaffoldInsets', () { - testWidgets('of() asserts when no ancestor is present', (tester) async { - await tester.pumpWidget( - _withStreamTheme(const Scaffold(body: _InsetsProbe())), - ); - - expect(tester.takeException(), isA()); - }); +/// A bare [PreferredSizeWidget] that does NOT self-inset the status bar — used +/// to exercise the top-inset formula without [StreamAppBar]'s internal padding. +PreferredSizeWidget _rawAppBar({double height = _kBarHeight, Key? childKey}) { + return PreferredSize( + preferredSize: Size.fromHeight(height), + child: SizedBox(key: childKey, height: height, width: double.infinity), + ); +} - testWidgets('maybeOf() returns null when no ancestor is present', (tester) async { - StreamScaffoldInsets? result; - await tester.pumpWidget( - _withStreamTheme( - Scaffold( - body: Builder( - builder: (context) { - result = StreamScaffoldInsets.maybeOf(context); - return const SizedBox(); - }, +/// Pumps a [StreamScaffold] with configurable behaviors and simulated device +/// insets (notch / home-indicator / keyboard) injected above the scaffold. +Future _pumpStreamScaffold( + WidgetTester tester, { + required Widget body, + StreamAppBarBehavior? appBarBehavior, + StreamBottomAppBarBehavior? bottomBarBehavior, + PreferredSizeWidget? appBar, + Widget? bottom, + bool resizeToAvoidBottomInset = true, + EdgeInsets devicePadding = EdgeInsets.zero, + EdgeInsets viewInsets = EdgeInsets.zero, + StreamAppStyle appStyle = StreamAppStyle.regular, +}) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + home: Builder( + builder: (context) { + final base = MediaQuery.of(context); + return MediaQuery( + data: base.copyWith( + padding: devicePadding, + viewPadding: devicePadding, + viewInsets: viewInsets, ), - ), - ), - ); - - expect(result, isNull); - }); - }); + child: StreamScaffold( + appBarBehavior: appBarBehavior, + bottomBarBehavior: bottomBarBehavior, + appBar: appBar, + bottom: bottom, + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + body: body, + ), + ); + }, + ), + ), + ); +} +void main() { group('when neither slot is floating', () { - testWidgets('injects zero insets and forwards drawer/endDrawer', (tester) async { + testWidgets('forwards drawer/endDrawer to the underlying Scaffold', (tester) async { const drawer = Drawer(key: ValueKey('drawer')); const endDrawer = Drawer(key: ValueKey('end-drawer')); @@ -58,13 +100,11 @@ void main() { const StreamScaffold( drawer: drawer, endDrawer: endDrawer, - body: _InsetsProbe(), + body: SizedBox(), ), ), ); - expect(find.text('top:0.0 bottom:0.0'), findsOneWidget); - final scaffold = tester.widget(find.byType(Scaffold)); expect(scaffold.drawer, same(drawer)); expect(scaffold.endDrawer, same(endDrawer)); @@ -87,42 +127,30 @@ void main() { }); group('when the app bar is floating', () { - testWidgets('extends the body behind the app bar and reports its height as topPadding', (tester) async { - const appBarHeight = kToolbarHeight; - + testWidgets('extends the body behind the app bar', (tester) async { await tester.pumpWidget( _withStreamTheme( const StreamScaffold( appBarBehavior: StreamAppBarBehavior.floating, - appBar: PreferredSize( - preferredSize: Size.fromHeight(appBarHeight), - child: SizedBox(), - ), - body: _InsetsProbe(), + appBar: PreferredSize(preferredSize: Size.fromHeight(kToolbarHeight), child: SizedBox()), + body: SizedBox(), ), ), ); final scaffold = tester.widget(find.byType(Scaffold)); expect(scaffold.extendBodyBehindAppBar, isTrue); - - final topPadding = MediaQuery.paddingOf(tester.element(find.byType(_InsetsProbe))).top; - expect(find.text('top:${appBarHeight + topPadding} bottom:0.0'), findsOneWidget); }); }); group('when the bottom widget is floating', () { - testWidgets('extends the body, drops bottomNavigationBar, and reports the bottom height as bottomPadding', ( - tester, - ) async { - const bottomHeight = 64.0; - + testWidgets('extends the body and drops the bottomNavigationBar slot', (tester) async { await tester.pumpWidget( _withStreamTheme( const StreamScaffold( bottomBarBehavior: StreamBottomAppBarBehavior.floating, - bottom: SizedBox(height: bottomHeight), - body: _InsetsProbe(), + bottom: SizedBox(height: 64), + body: SizedBox(), ), ), ); @@ -130,25 +158,22 @@ void main() { final scaffold = tester.widget(find.byType(Scaffold)); expect(scaffold.extendBody, isTrue); expect(scaffold.bottomNavigationBar, isNull); - expect(find.text('top:0.0 bottom:$bottomHeight'), findsOneWidget); }); - testWidgets('reports the updated bottomPadding after the bottom widget resizes', (tester) async { - Widget buildWithHeight(double height) { - return _withStreamTheme( - StreamScaffold( - bottomBarBehavior: StreamBottomAppBarBehavior.floating, - bottom: SizedBox(height: height), - body: const _InsetsProbe(), - ), - ); - } + testWidgets('tracks the bottom bar height into MediaQuery.padding when it resizes', (tester) async { + final captured = _CapturedInsets(); + Future pumpWithHeight(double height) => _pumpStreamScaffold( + tester, + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + bottom: SizedBox(height: height), + body: _InsetProbe(captured), + ); - await tester.pumpWidget(buildWithHeight(64)); - expect(find.text('top:0.0 bottom:64.0'), findsOneWidget); + await pumpWithHeight(64); + expect(captured.padding!.bottom, 64); - await tester.pumpWidget(buildWithHeight(80)); - expect(find.text('top:0.0 bottom:80.0'), findsOneWidget); + await pumpWithHeight(80); + expect(captured.padding!.bottom, 80); }); testWidgets('is not floating when no bottom widget is provided', (tester) async { @@ -156,14 +181,13 @@ void main() { _withStreamTheme( const StreamScaffold( bottomBarBehavior: StreamBottomAppBarBehavior.floating, - body: _InsetsProbe(), + body: SizedBox(), ), ), ); final scaffold = tester.widget(find.byType(Scaffold)); expect(scaffold.extendBody, isFalse); - expect(find.text('top:0.0 bottom:0.0'), findsOneWidget); }); }); @@ -203,6 +227,424 @@ void main() { }); }); + // P0 — correctness of the injection -------------------------------------- + + group('inset injection · mode matrix', () { + const device = EdgeInsets.only(top: 44, bottom: 34); + const bottomBarHeight = 64.0; + + testWidgets('both regular → no floating inset added', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + appBar: _rawAppBar(), + bottom: const SizedBox(height: bottomBarHeight), + devicePadding: device, + body: _InsetProbe(captured), + ); + + // A regular app bar consumes the system top; nothing enlarges it. + expect(captured.padding!.top, lessThan(_kBarHeight)); + }); + + testWidgets('floating app bar → padding.top = measured app-bar height', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + devicePadding: device, + body: _InsetProbe(captured), + ); + + expect(captured.padding!.top, _kBarHeight); // max(44 system, 56 measured bar) + expect(captured.padding!.bottom, 34); // system inset preserved + }); + + testWidgets('floating bottom → padding.bottom = measured bar height', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + bottom: const SizedBox(height: bottomBarHeight), + devicePadding: device, + body: _InsetProbe(captured), + ); + + expect(captured.padding!.top, 44); // system inset preserved + expect(captured.padding!.bottom, bottomBarHeight); // max(34, 64) + }); + + testWidgets('both floating → both insets injected', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + appBar: _rawAppBar(), + bottom: const SizedBox(height: bottomBarHeight), + devicePadding: device, + body: _InsetProbe(captured), + ); + + expect(captured.padding!.top, _kBarHeight); + expect(captured.padding!.bottom, bottomBarHeight); + }); + }); + + group('inset injection · top is the measured app-bar height', () { + const device = EdgeInsets.only(top: 44); + const firstItemKey = ValueKey('first'); + + testWidgets('padding.top follows the app-bar height, not a fixed formula', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(height: 80), + devicePadding: device, + body: _InsetProbe(captured), + ); + + expect(captured.padding!.top, 80); // measured 80px bar, not 80 + system top + }); + + testWidgets('a non-self-insetting bar insets content to its exact bottom edge (no gap)', (tester) async { + const barKey = ValueKey('bar'); + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(childKey: barKey), + devicePadding: device, + body: ListView( + children: const [ + SizedBox(key: firstItemKey, height: 40), + SizedBox(height: 1000), + ], + ), + ); + + final barBottom = tester.getRect(find.byKey(barKey)).bottom; + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + expect(barBottom, _kBarHeight); // raw bar renders at 0..56, no self-inset + // Measured top → the first item sits exactly at the bar's bottom, no gap. + expect(firstItemTop, barBottom); + }); + + testWidgets('StreamAppBar(primary: true) self-insets, so the body aligns with its bottom edge', (tester) async { + const firstItemKey = ValueKey('first'); + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: StreamAppBar( + automaticallyImplyLeading: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + title: const Text('Title'), + ), + devicePadding: device, + body: ListView( + children: const [ + SizedBox(key: firstItemKey, height: 40), + SizedBox(height: 1000), + ], + ), + ); + + final barBottom = tester.getRect(find.byType(StreamAppBar)).bottom; + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + expect(firstItemTop, kStreamToolbarHeight + 44); + expect(firstItemTop, moreOrLessEquals(barBottom, epsilon: 0.5)); // aligned, no gap + }); + }); + + // P1 — double-inset & regression risks ------------------------------------ + + group('auto-inset behaviour for scrollables', () { + const device = EdgeInsets.only(top: 44); + const firstItemKey = ValueKey('first'); + + Widget listBody({EdgeInsets? padding}) => ListView( + padding: padding, + children: const [ + SizedBox(key: firstItemKey, height: 40), + SizedBox(height: 1000), + ], + ); + + testWidgets('null-padding ListView auto-insets while its viewport still spans full height', (tester) async { + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + devicePadding: device, + body: listBody(), + ); + + final listTop = tester.getRect(find.byType(ListView)).top; + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + expect(listTop, 0); // viewport fills → content scrolls behind the bar + expect(firstItemTop, _kBarHeight); // first item rests clear + }); + + testWidgets('explicit padding opts out of auto-inset', (tester) async { + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + devicePadding: device, + body: listBody(padding: EdgeInsets.zero), + ); + + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + expect(firstItemTop, 0); // injection ignored — developer opted out + }); + + testWidgets('SafeArea shrinks the viewport instead of scrolling behind, and does not double-inset', ( + tester, + ) async { + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + devicePadding: device, + body: SafeArea(child: listBody()), + ); + + final listTop = tester.getRect(find.byType(ListView)).top; + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + expect(listTop, _kBarHeight); // viewport pushed down (shrunk) + expect(firstItemTop, _kBarHeight); // inset once, not doubled + }); + }); + + group('documented surprises', () { + const device = EdgeInsets.only(top: 44); + const headerKey = ValueKey('header'); + const firstItemKey = ValueKey('first'); + + testWidgets('a non-top-level ListView still consumes the injected top padding (gap after header)', ( + tester, + ) async { + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + devicePadding: device, + body: Column( + children: [ + const SizedBox(key: headerKey, height: 40, width: double.infinity), + Expanded( + child: ListView( + children: const [ + SizedBox(key: firstItemKey, height: 40), + SizedBox(height: 1000), + ], + ), + ), + ], + ), + ); + + final headerBottom = tester.getRect(find.byKey(headerKey)).bottom; + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + // The inner ListView adds the full injected top inset — max(system-top, + // barHeight) — even though it is not the top-level scrollable, producing a + // gap after the header. + expect(firstItemTop - headerBottom, _kBarHeight); + }); + + testWidgets('reading MediaQuery.paddingOf AND leaving a BoxScrollView null-padding double-insets', ( + tester, + ) async { + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + devicePadding: device, + body: Builder( + builder: (context) { + final topInset = MediaQuery.paddingOf(context).top; + return ListView( + children: [ + SizedBox(height: topInset), // manual read … + const SizedBox(key: firstItemKey, height: 40), + ], + ); + }, + ), + ); + + // … while the null-padding ListView ALSO auto-consumes it → 2× inset. + final firstItemTop = tester.getRect(find.byKey(firstItemKey)).top; + expect(firstItemTop, _kBarHeight * 2); + }); + + // Mixed mode: floating app bar (top injected) + REGULAR bottom (docked, so + // the body's bottom padding is stripped). This is what makes the channel + // list's `bottomPadding > 0 ? … : null` resolve to null → the inner ListView + // auto-consumes the top inset → gap after the header. + Future pumpMixedMode(WidgetTester tester, {required bool alwaysExplicit}) async { + const searchKey = ValueKey('search'); + const firstKey = ValueKey('first'); + var capturedBottom = -1.0; + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + bottomBarBehavior: StreamBottomAppBarBehavior.regular, + bottom: const SizedBox(height: 80), + devicePadding: const EdgeInsets.only(top: 44, bottom: 34), + body: IndexedStack( + children: [ + NestedScrollView( + headerSliverBuilder: (ctx, _) { + final topInset = MediaQuery.paddingOf(ctx).top; + return [ + SliverToBoxAdapter(child: SizedBox(height: topInset)), + const SliverToBoxAdapter(child: SizedBox(key: searchKey, height: 40)), + ]; + }, + body: Builder( + builder: (ctx) { + capturedBottom = MediaQuery.paddingOf(ctx).bottom; + final padding = alwaysExplicit + ? EdgeInsets.only(bottom: capturedBottom) // fix: always explicit + : (capturedBottom > 0 ? EdgeInsets.only(bottom: capturedBottom) : null); // current: null + return ListView( + padding: padding, + children: const [ + SizedBox(key: firstKey, height: 40), + SizedBox(height: 2000), + ], + ); + }, + ), + ), + ], + ), + ); + expect(capturedBottom, 0); // regular bottom strips the body's bottom inset + return tester.getRect(find.byKey(firstKey)).top - tester.getRect(find.byKey(searchKey)).bottom; + } + + testWidgets('floating app bar + regular bottom: `… : null` padding re-consumes the top inset (gap)', ( + tester, + ) async { + // A regular bottom zeroes bottomPadding, so `bottomPadding > 0 ? … : null` + // yields null → the non-top-level ListView auto-consumes the top inset. + final gap = await pumpMixedMode(tester, alwaysExplicit: false); + expect(gap, _kBarHeight); + }); + + testWidgets('… always-explicit padding (EdgeInsets.only(bottom: 0)) opts out → no gap', (tester) async { + final gap = await pumpMixedMode(tester, alwaysExplicit: true); + expect(gap, 0); + }); + }); + + group('keyboard interaction (floating bottom)', () { + const bottomBarHeight = 64.0; + const keyboard = 300.0; + + testWidgets('padding.bottom carries the bar height, not the keyboard', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + bottom: const SizedBox(height: bottomBarHeight), + devicePadding: const EdgeInsets.only(bottom: 34), + viewInsets: const EdgeInsets.only(bottom: keyboard), + body: _InsetProbe(captured), + ); + + expect(captured.padding!.bottom, bottomBarHeight); // 64, not 364 — keyboard not folded into padding + }); + + testWidgets('floating bottom bar rides above the keyboard when resizeToAvoidBottomInset is true', (tester) async { + const barKey = ValueKey('bar'); + await _pumpStreamScaffold( + tester, + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + bottom: const SizedBox(key: barKey, height: bottomBarHeight), + viewInsets: const EdgeInsets.only(bottom: keyboard), + body: const SizedBox.expand(), + ); + + final surfaceHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + final barBottom = tester.getRect(find.byKey(barKey)).bottom; + expect(barBottom, lessThanOrEqualTo(surfaceHeight - keyboard + 0.5)); + }); + + testWidgets('floating bottom bar stays at the surface bottom when resizeToAvoidBottomInset is false', ( + tester, + ) async { + const barKey = ValueKey('bar'); + await _pumpStreamScaffold( + tester, + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + bottom: const SizedBox(key: barKey, height: bottomBarHeight), + viewInsets: const EdgeInsets.only(bottom: keyboard), + resizeToAvoidBottomInset: false, + body: const SizedBox.expand(), + ); + + final surfaceHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + final barBottom = tester.getRect(find.byKey(barKey)).bottom; + expect(barBottom, moreOrLessEquals(surfaceHeight, epsilon: 0.5)); + }); + }); + + group('regular bottom with a floating app bar', () { + testWidgets('keeps a regular bottom out of the bottomNavigationBar slot', (tester) async { + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + bottomBarBehavior: StreamBottomAppBarBehavior.regular, + bottom: const SizedBox(height: 64), + body: const SizedBox.expand(), + ); + + // A regular bottom must never sit in bottomNavigationBar (that slot is not + // lifted above the keyboard), regardless of the app-bar behavior. + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.bottomNavigationBar, isNull); + }); + + testWidgets('a regular bottom rides above the keyboard rather than behind it', (tester) async { + const barKey = ValueKey('bar'); + const keyboard = 300.0; + + await _pumpStreamScaffold( + tester, + appBarBehavior: StreamAppBarBehavior.floating, + appBar: _rawAppBar(), + bottomBarBehavior: StreamBottomAppBarBehavior.regular, + bottom: const SizedBox(key: barKey, height: 64), + viewInsets: const EdgeInsets.only(bottom: keyboard), + body: const SizedBox.expand(), + ); + + final surfaceHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + final barBottom = tester.getRect(find.byKey(barKey)).bottom; + expect(barBottom, lessThanOrEqualTo(surfaceHeight - keyboard + 0.5)); + }); + }); + + testWidgets('a docked (regular) bottom strips the bottom system inset from the body', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + bottom: const SizedBox(height: 64), // regular / docked + devicePadding: const EdgeInsets.only(bottom: 34), + body: _InsetProbe(captured), + ); + + // The docked bottom owns the home-indicator inset, so the body sees 0 — its + // scrollables rest on the bottom widget, not 34px above it. + expect(captured.padding!.bottom, 0); + }); + testWidgets('applies the given background color', (tester) async { const backgroundColor = Color(0xFF123456); diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_floating.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_floating.png index cafa4f4c11f93b9a630f2f4e84c9437c8990efaa..f7116ab44c021964fb373895836ba5c8a9e2e638 100644 GIT binary patch literal 11172 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