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..4dbdef0e 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' @@ -124,6 +126,8 @@ import 'package:design_system_gallery/components/toolbar/stream_bottom_nav_bar.d as _design_system_gallery_components_toolbar_stream_bottom_nav_bar; import 'package:design_system_gallery/components/toolbar/stream_sheet_header.dart' as _design_system_gallery_components_toolbar_stream_sheet_header; +import 'package:design_system_gallery/components/toolbar/stream_toolbar_button.dart' + as _design_system_gallery_components_toolbar_stream_toolbar_button; import 'package:design_system_gallery/primitives/colors.dart' as _design_system_gallery_primitives_colors; import 'package:design_system_gallery/primitives/icons.dart' @@ -1133,6 +1137,28 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Scaffold', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamScaffold', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_scaffold_stream_scaffold + .buildStreamScaffoldPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: + _design_system_gallery_components_scaffold_stream_scaffold + .buildStreamScaffoldDrawers, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Sheet', children: [ @@ -1268,6 +1294,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamToolbarButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_toolbar_stream_toolbar_button + .buildStreamToolbarButtonPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: + _design_system_gallery_components_toolbar_stream_toolbar_button + .buildStreamToolbarButtonRealWorld, + ), + ], + ), ], ), ], diff --git a/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart b/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart index dfc889dd..4ffa06c6 100644 --- a/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart +++ b/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart @@ -41,13 +41,24 @@ Widget buildStreamMediaViewerPlayground(BuildContext context) { description: 'Duration of the chrome show/hide animation.', ); + final surfaceStyle = context.knobs.object.dropdown( + label: 'Chrome surfaceStyle', + options: StreamSurfaceStyle.values, + initialOption: StreamSurfaceStyle.floating, + description: + 'Floating chrome overlays full-bleed media with a gradient fade; ' + 'regular chrome insets the media between opaque bars.', + ); + final tintChrome = context.knobs.boolean( label: 'Tint chrome over dark media', description: 'Demonstrates StreamMediaViewerThemeData.appBarStyle / ' - 'bottomAppBarStyle — scopes a translucent chrome over the media.', + 'bottomAppBarStyle — scopes a translucent chrome colour over the media.', ); + final tint = tintChrome ? const Color(0x55000000) : null; + return _MediaViewerLauncher( label: 'Open media viewer', onPressed: (launchContext) => _push( @@ -55,8 +66,16 @@ Widget buildStreamMediaViewerPlayground(BuildContext context) { StreamMediaViewerTheme( data: StreamMediaViewerThemeData( chromeAnimationDuration: Duration(milliseconds: animationMs.round()), - appBarStyle: tintChrome ? const StreamAppBarStyle(backgroundColor: Color(0x55000000)) : null, - bottomAppBarStyle: tintChrome ? const StreamBottomAppBarStyle(backgroundColor: Color(0x55000000)) : null, + appBarStyle: StreamAppBarStyle( + surfaceStyle: surfaceStyle, + backgroundColor: tint, + floatingBackgroundColor: tint, + ), + bottomAppBarStyle: StreamBottomAppBarStyle( + surfaceStyle: surfaceStyle, + backgroundColor: tint, + floatingBackgroundColor: tint, + ), ), child: _PlaygroundMediaViewer( showHeader: showHeader, 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..41baf704 --- /dev/null +++ b/apps/design_system_gallery/lib/components/scaffold/stream_scaffold.dart @@ -0,0 +1,389 @@ +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 appBarSurfaceStyle = context.knobs.object.dropdown( + label: 'App bar surfaceStyle', + options: StreamSurfaceStyle.values, + labelBuilder: (value) => value.name, + initialOption: StreamSurfaceStyle.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 bottomSurfaceStyle = context.knobs.object.dropdown( + label: 'Bottom bar surfaceStyle', + options: StreamSurfaceStyle.values, + labelBuilder: (value) => value.name, + initialOption: StreamSurfaceStyle.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 = appBarSurfaceStyle == StreamSurfaceStyle.floating; + final bottomFloating = bottomSurfaceStyle == StreamSurfaceStyle.floating; + + final scaffold = StreamScaffold( + appBarSurfaceStyle: appBarSurfaceStyle, + bottomSurfaceStyle: bottomSurfaceStyle, + 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: 'Real-world Example', + 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( + surfaceStyle: floating ? StreamSurfaceStyle.floating : StreamSurfaceStyle.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), + style: StreamBottomNavBarStyle( + surfaceStyle: widget.floating ? StreamSurfaceStyle.floating : StreamSurfaceStyle.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/apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart b/apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart index 6d8907ac..19fc8092 100644 --- a/apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart +++ b/apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart @@ -65,7 +65,7 @@ Widget buildStreamAppBarPlayground(BuildContext context) { final Widget bar = StreamAppBar( primary: false, style: StreamAppBarStyle( - behavior: floating ? StreamAppBarBehavior.floating : StreamAppBarBehavior.regular, + surfaceStyle: floating ? StreamSurfaceStyle.floating : StreamSurfaceStyle.regular, padding: EdgeInsets.all(padding), spacing: spacing, ), @@ -264,7 +264,7 @@ Widget buildStreamAppBarShowcase(BuildContext context) { bar: StreamAppBar( automaticallyImplyLeading: false, primary: false, - style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), leading: StreamButton.icon( icon: Icon(context.streamIcons.chevronLeft), style: StreamButtonStyle.secondary, diff --git a/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart index 3cf3ca65..4e6aa92a 100644 --- a/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart +++ b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart @@ -58,34 +58,62 @@ Widget buildStreamBottomAppBarPlayground(BuildContext context) { 'the system bottom inset (home indicator).', ); - return Align( - alignment: Alignment.bottomCenter, - child: StreamBottomAppBar( - primary: primary, - style: StreamBottomAppBarStyle( - padding: EdgeInsets.all(padding), - spacing: spacing, - ), - leading: showLeading - ? StreamButton.icon( - icon: Icon(context.streamIcons.export), - style: StreamButtonStyle.secondary, - type: StreamButtonType.ghost, - onPressed: () {}, - ) - : null, - title: (title != null && title.isNotEmpty) ? Text(title) : null, - subtitle: (subtitle != null && subtitle.isNotEmpty) ? Text(subtitle) : null, - trailing: showTrailing - ? StreamButton.icon( - icon: Icon(context.streamIcons.gallery), - style: StreamButtonStyle.secondary, - type: StreamButtonType.ghost, - onPressed: () {}, - ) - : null, + final floating = context.knobs.boolean( + label: 'Floating', + description: + 'When true, the bottom bar floats above content with a gradient fade ' + 'instead of a solid background and a top border.', + ); + + final colorScheme = context.streamColorScheme; + + final Widget bar = StreamBottomAppBar( + primary: primary, + style: StreamBottomAppBarStyle( + surfaceStyle: floating ? StreamSurfaceStyle.floating : StreamSurfaceStyle.regular, + padding: EdgeInsets.all(padding), + spacing: spacing, ), + leading: showLeading + ? StreamButton.icon( + icon: Icon(context.streamIcons.export), + style: StreamButtonStyle.secondary, + type: floating ? StreamButtonType.outline : StreamButtonType.ghost, + isFloating: floating, + onPressed: () {}, + ) + : null, + title: (title != null && title.isNotEmpty) ? Text(title) : null, + subtitle: (subtitle != null && subtitle.isNotEmpty) ? Text(subtitle) : null, + trailing: showTrailing + ? StreamButton.icon( + icon: Icon(context.streamIcons.gallery), + style: StreamButtonStyle.secondary, + type: floating ? StreamButtonType.outline : StreamButtonType.ghost, + isFloating: floating, + onPressed: () {}, + ) + : null, ); + + if (floating) { + return Stack( + children: [ + Positioned.fill( + child: ColoredBox( + color: colorScheme.backgroundApp, + child: Container( + margin: EdgeInsets.symmetric(horizontal: context.streamSpacing.xxl), + decoration: BoxDecoration(color: colorScheme.accentPrimary), + ), + ), + ), + Align(alignment: Alignment.bottomCenter, child: bar), + ], + ); + } + + return Align(alignment: Alignment.bottomCenter, child: bar); } // ============================================================================= diff --git a/apps/design_system_gallery/lib/components/toolbar/stream_bottom_nav_bar.dart b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_nav_bar.dart index 5ff177f9..d8bea558 100644 --- a/apps/design_system_gallery/lib/components/toolbar/stream_bottom_nav_bar.dart +++ b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_nav_bar.dart @@ -81,7 +81,7 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { items: items, currentIndex: currentIndex, onTap: (index) => setState(() => _currentIndex = index), - behavior: floating ? StreamBottomNavBarBehavior.floating : StreamBottomNavBarBehavior.regular, + style: StreamBottomNavBarStyle(surfaceStyle: floating ? .floating : .regular), ), ); } @@ -131,7 +131,7 @@ class _ShowcaseDemoState extends State<_ShowcaseDemo> { items: items, currentIndex: _regularIndex, onTap: (index) => setState(() => _regularIndex = index), - behavior: StreamBottomNavBarBehavior.regular, + style: const StreamBottomNavBarStyle(surfaceStyle: .regular), ), ), SizedBox(height: spacing.md), @@ -142,7 +142,7 @@ class _ShowcaseDemoState extends State<_ShowcaseDemo> { items: items, currentIndex: _floatingIndex, onTap: (index) => setState(() => _floatingIndex = index), - behavior: StreamBottomNavBarBehavior.floating, + style: const StreamBottomNavBarStyle(surfaceStyle: .floating), ), ), ), diff --git a/apps/design_system_gallery/lib/components/toolbar/stream_toolbar_button.dart b/apps/design_system_gallery/lib/components/toolbar/stream_toolbar_button.dart new file mode 100644 index 00000000..d815ddf1 --- /dev/null +++ b/apps/design_system_gallery/lib/components/toolbar/stream_toolbar_button.dart @@ -0,0 +1,84 @@ +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: StreamToolbarButton, + path: '[Components]/Toolbar', +) +Widget buildStreamToolbarButtonPlayground(BuildContext context) { + final surfaceStyle = context.knobs.object.dropdown( + label: 'Toolbar surface style', + options: StreamSurfaceStyle.values, + labelBuilder: (value) => value.name, + initialOption: StreamSurfaceStyle.floating, + description: + 'The enclosing toolbar style, published via StreamToolbarScope. ' + 'floating → outlined + elevated; regular → ghost.', + ); + + final label = context.knobs.stringOrNull( + label: 'Label', + initialValue: 'Edit', + description: 'Text for a labelled button. Clear to show the icon-only variant.', + ); + + final enabled = context.knobs.boolean( + label: 'Enabled', + initialValue: true, + description: 'When off, the button is rendered disabled (onPressed null).', + ); + + final onPressed = enabled ? () {} : null; + + final Widget button = (label != null && label.isNotEmpty) + ? StreamToolbarButton(onPressed: onPressed, child: Text(label)) + : StreamToolbarButton.icon( + icon: Icon(context.streamIcons.checkmark), + tooltip: 'Confirm', + onPressed: onPressed, + ); + + // The button resolves its look from the enclosing StreamToolbarScope — the + // same scope an app bar / bottom app bar publishes to its slots. + return StreamToolbarScope( + surfaceStyle: surfaceStyle, + child: Center(child: button), + ); +} + +// ============================================================================= +// Real-world Example +// ============================================================================= + +@widgetbook.UseCase( + name: 'Real-world Example', + type: StreamToolbarButton, + path: '[Components]/Toolbar', +) +Widget buildStreamToolbarButtonRealWorld(BuildContext context) { + final colorScheme = context.streamColorScheme; + + // A floating app bar with a trailing StreamToolbarButton — the button picks + // up the bar's floating look automatically through the toolbar scope. + return ColoredBox( + color: colorScheme.backgroundApp, + child: Align( + alignment: Alignment.topCenter, + child: StreamAppBar( + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + title: const Text('Profile'), + trailing: StreamToolbarButton( + onPressed: () {}, + child: const Text('Edit'), + ), + ), + ), + ); +} diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index a7e9b6f1..bc8b13ca 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -6,10 +6,14 @@ - 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 `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. +- 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; each item takes an icon, a label, and optional `selectedIcon`, `tooltip`, `semanticsLabel`, and `key`. Renders either a regular docked bar or a floating pill with a gradient fade-out beneath it, resolved from `StreamBottomNavBarStyle.surfaceStyle` (a shared `StreamSurfaceStyle`, per-instance via `style` or the ambient `StreamBottomNavBarTheme`), then the app-wide `StreamSurfaceStyle`. 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, and floating elevation). The bar's height is the public `kStreamBottomNavBarHeight` constant (64). Items announce themselves as accessible buttons via `Semantics`, with `semanticsLabel` overriding the spoken label. +- Added `StreamSafeArea` — a `SafeArea` variant that insets its child by `max(systemInset, minimum) + margin` on each edge: `minimum` floors an edge (like `SafeArea.minimum`) while `margin` adds beyond the safe area, so a pinned surface keeps a controlled gap from the status bar / navigation bar / home indicator rather than sitting flush. The bottom gap is measured from `padding` by default, matching `SafeArea`; set `maintainBottomViewPadding: true` to measure it from `viewPadding` instead so a pinned surface stays put when a keyboard covers it. Resolve the same insets as a value — e.g. to size a gradient behind the child — with the static `StreamSafeArea.resolveInsets`. `StreamSafeArea.driven` interpolates the inset toward a target (default `EdgeInsets.zero`) driven by any `ValueListenable`, for a floating surface that releases its space as a panel slides in beneath it. Used internally by the floating `StreamBottomNavBar` pill. +- Added floating-bar support to `StreamAppBar` and `StreamBottomAppBar` via `StreamAppBarStyle.surfaceStyle` / `StreamBottomAppBarStyle.surfaceStyle` (a shared `StreamSurfaceStyle`). When `StreamSurfaceStyle.floating`, the bar renders above the body with a translucent background and a gradient overlay instead of its solid background and hairline border; the bottom bar fades upward (new `StreamBottomAppBarStyle.floatingBackgroundColor`, defaulting to `backgroundElevation0`). +- Each bar publishes its resolved behaviour to a `StreamToolbarScope` around its slots, so a widget placed in a `leading` / `title` / `trailing` slot resolves the same floating/regular state as the bar — including when the behaviour is set through `style` passed only to the bar. Read it with `StreamToolbarScope.of(context)`. +- Added `StreamToolbarButton` — a toolbar action button (labelled, or icon-only via `StreamToolbarButton.icon`) that resolves its look from the enclosing `StreamToolbarScope`: a labelled action is outlined whether docked or floating (elevated when floating), while an icon-only action is outlined when floating and ghost when docked. Pass `type` to override the resolved shape (e.g. a `solid` primary action) while keeping the bar-driven elevation. The app bar's auto-implied back button is one, and it is available for custom leading / trailing / footer actions. +- `StreamMediaViewer` chrome (header / footer) now follows the ambient `StreamSurfaceStyle` — floating over full-bleed media when the app style is floating, docked with the media inset between the bars when regular. Pin it per-viewer with an explicit `surfaceStyle` on `StreamMediaViewerThemeData.appBarStyle` / `.bottomAppBarStyle`. +- Added `isFloating` to `StreamAvatar`, `StreamAvatarGroup`, and `StreamAvatarStack`. When true, the avatar renders with a Material elevation (`StreamAvatarThemeData.floatingElevation`, defaulting to `StreamElevation.level3`) 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. - Added `streamFloatingFade` helper — a shared `LinearGradient` factory (alpha stops `0xE8/0xA8/0x40/0x00` with solid-fraction support for safe-area zones) used internally by `StreamAppBar`, `StreamBottomNavBar`, and `StreamMessageComposer` floating fade effects. - Added `isFloating` to the default `StreamButton` constructor — the floating (elevated) appearance was previously reachable only through `StreamButton.icon`. Labelled buttons now get the same treatment: elevation for every type, plus a `backgroundElevation1` fill for `outline` and `ghost`. - Added `StreamElevation` — the four elevation levels of the design system as logical pixels, for passing to `Material.elevation` or a component theme's `elevation` field. Like `StreamRadius` and `StreamSpacing` it is a theme primitive: reachable as `StreamTheme.elevation` or `context.streamElevation`, overridable per theme through the `StreamTheme` constructor, and lerped on theme transitions. `StreamElevation.none` is a fixed `0` rather than a themeable level, so "flat" cannot be redefined as elevated. `StreamAvatar` and `StreamButton` now resolve their elevations from it instead of hard-coded numbers; the rendered values are unchanged. diff --git a/packages/stream_core_flutter/lib/core.dart b/packages/stream_core_flutter/lib/core.dart index 64fab11e..fa8e0dee 100644 --- a/packages/stream_core_flutter/lib/core.dart +++ b/packages/stream_core_flutter/lib/core.dart @@ -34,6 +34,7 @@ export 'src/components/common/stream_intrinsic_flex.dart'; export 'src/components/common/stream_loading_spinner.dart'; export 'src/components/common/stream_network_image.dart'; export 'src/components/common/stream_progress_bar.dart'; +export 'src/components/common/stream_safe_area.dart'; export 'src/components/common/stream_skeleton_loading.dart'; export 'src/components/common/stream_tap_target_padding.dart'; export 'src/components/common/stream_text_input.dart'; @@ -60,8 +61,9 @@ export 'src/components/toolbar/stream_bottom_app_bar.dart'; export 'src/components/toolbar/stream_bottom_nav_bar.dart'; export 'src/components/toolbar/stream_sheet_header.dart'; export 'src/components/toolbar/stream_toolbar.dart'; +export 'src/components/toolbar/stream_toolbar_button.dart'; +export 'src/components/toolbar/stream_toolbar_scope.dart'; export 'src/factory/stream_component_factory.dart'; - export 'src/theme/components/stream_app_bar_theme.dart'; export 'src/theme/components/stream_audio_waveform_theme.dart'; export 'src/theme/components/stream_avatar_theme.dart'; @@ -87,20 +89,17 @@ export 'src/theme/components/stream_snackbar_theme.dart'; export 'src/theme/components/stream_stepper_theme.dart'; export 'src/theme/components/stream_switch_theme.dart'; export 'src/theme/components/stream_text_input_theme.dart'; - export 'src/theme/primitives/stream_colors.dart'; export 'src/theme/primitives/stream_elevation.dart'; export 'src/theme/primitives/stream_icons.dart'; export 'src/theme/primitives/stream_radius.dart'; export 'src/theme/primitives/stream_spacing.dart'; export 'src/theme/primitives/stream_typography.dart'; - export 'src/theme/semantics/stream_box_shadow.dart'; export 'src/theme/semantics/stream_color_scheme.dart'; export 'src/theme/semantics/stream_text_theme.dart'; - -export 'src/theme/stream_app_style.dart'; export 'src/theme/stream_floating_fade.dart'; +export 'src/theme/stream_surface_style.dart'; export 'src/theme/stream_theme.dart'; export 'src/theme/stream_theme_extensions.dart'; export 'src/theme/widget_state_utils.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart index 5ff5f270..3dde3f05 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart @@ -175,7 +175,7 @@ class StreamAvatarProps { /// Whether this avatar is in a floating state, rendering with a drop shadow. /// /// When true, the elevation is taken from [StreamAvatarThemeData.floatingElevation], - /// falling back to `3`. When false or null (resolved to false), no shadow is shown. + /// falling back to `6`. When false or null (resolved to false), no shadow is shown. final bool? isFloating; /// Screen-reader label for the avatar. @@ -320,7 +320,7 @@ class _StreamAvatarThemeDefaults extends StreamAvatarThemeData { final StreamElevation _elevation; @override - double get floatingElevation => _elevation.level2; + double get floatingElevation => _elevation.level3; @override StreamAvatarSize get size => StreamAvatarSize.lg; diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_safe_area.dart b/packages/stream_core_flutter/lib/src/components/common/stream_safe_area.dart new file mode 100644 index 00000000..281c99fa --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_safe_area.dart @@ -0,0 +1,218 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that insets its child to avoid intrusions by the operating system, +/// with a [minimum] floor and an added [margin], so the child keeps a +/// controlled gap from them rather than sitting flush. +/// +/// Each edge is inset by `max(systemInset, minimum) + margin`. Like +/// [SafeArea.minimum], [minimum] raises an edge to at least that much (a larger +/// system inset absorbs it); [margin] is then added on top of every edge. With +/// both at their defaults the widget behaves like a plain [SafeArea]. +/// +/// Use the default constructor for a constant inset. Use [StreamSafeArea.driven] +/// when the inset should interpolate toward a target driven by a +/// `ValueListenable` — for a floating surface that hands its space to a +/// panel sliding in beneath it (e.g. a composer whose bottom inset gives way as +/// an attachment picker opens full-bleed). +/// +/// {@tool snippet} +/// +/// This example keeps a bar at least `32` clear of the bottom of the screen, +/// and more on devices that reserve more: +/// +/// ```dart +/// StreamSafeArea( +/// top: false, +/// minimum: const EdgeInsets.only(bottom: 32), +/// child: myBar, +/// ) +/// ``` +/// {@end-tool} +/// +/// ### [MediaQuery] impact +/// +/// Both constructors remove the avoided system insets from the [child]'s +/// [MediaQuery], so a nested safe area doesn't inset the same intrusion twice; +/// [minimum] and [margin] are not removed. For [StreamSafeArea.driven] the +/// removal is independent of the interpolation — the avoided edges are marked +/// handled even as the visible inset collapses toward `to`. +/// +/// See also: +/// +/// * [SafeArea], which insets only to the safe area (with an optional minimum +/// floor) and cannot add a margin beyond it. +/// * [Padding], for insetting widgets in general. +/// * [MediaQuery], from which the safe area is obtained. +class StreamSafeArea extends StatelessWidget { + /// Creates a widget that avoids operating system intrusions by at least + /// [minimum], plus [margin]. + const StreamSafeArea({ + super.key, + this.left = true, + this.top = true, + this.right = true, + this.bottom = true, + this.minimum = EdgeInsets.zero, + this.margin = EdgeInsets.zero, + this.maintainBottomViewPadding = false, + required this.child, + }) : _listenable = null, + _to = EdgeInsets.zero; + + /// Creates a safe area whose inset interpolates toward [to] as [listenable] + /// goes from `0` to `1`. + /// + /// At `0` the full `max(systemInset, minimum) + margin` is applied; at `1` + /// the applied inset is [to] — `EdgeInsets.zero` by default, i.e. the [child] + /// extends edge-to-edge. Drive it with any `ValueListenable` (an + /// [Animation], a `ValueNotifier`, …), typically the one that reveals + /// whatever takes over the space, so the inset gives way in step. Values are + /// clamped to `[0, 1]`. + const StreamSafeArea.driven({ + super.key, + required ValueListenable listenable, + EdgeInsets to = EdgeInsets.zero, + this.left = true, + this.top = true, + this.right = true, + this.bottom = true, + this.minimum = EdgeInsets.zero, + this.margin = EdgeInsets.zero, + this.maintainBottomViewPadding = false, + required this.child, + }) : _listenable = listenable, + _to = to; + + /// Whether to avoid system intrusions on the left ([minimum] and [margin] apply either way). + final bool left; + + /// Whether to avoid system intrusions at the top of the screen, typically the + /// system status bar ([minimum] and [margin] apply either way). + final bool top; + + /// Whether to avoid system intrusions on the right ([minimum] and [margin] apply either way). + final bool right; + + /// Whether to avoid system intrusions on the bottom of the screen, typically + /// the navigation bar or home indicator ([minimum] and [margin] apply either way). + final bool bottom; + + /// The minimum inset to apply on each edge. + /// + /// The greater of this and the system inset is used, before [margin] is added. + final EdgeInsets minimum; + + /// The margin to apply beyond the safe area. + /// + /// Added to every edge on top of the system inset (or [minimum], whichever is + /// greater). + final EdgeInsets margin; + + /// Specifies whether this widget should maintain the bottom + /// [MediaQueryData.viewPadding] instead of the bottom [MediaQueryData.padding], + /// defaults to false. + /// + /// For example, if there is an onscreen keyboard displayed above this widget, + /// the bottom gap can be maintained above the obstruction rather than being + /// consumed. This is helpful when the layout contains flexible widgets that + /// would otherwise visibly move when the keyboard opens due to the change in + /// the padding value. Setting this to true avoids that shift. + final bool maintainBottomViewPadding; + + /// The widget below this widget in the tree. + /// + /// The padding on the [MediaQuery] for the [child] is adjusted to zero out any + /// sides that were avoided by this widget. + final Widget child; + + /// Drives the interpolation for [StreamSafeArea.driven]; null for the default. + final ValueListenable? _listenable; + + /// The inset [StreamSafeArea.driven] interpolates toward at `1`. + final EdgeInsets _to; + + /// The insets this widget applies for [context] with the given options — + /// `max(systemInset, minimum) + margin` on each edge. This is the full value; + /// [StreamSafeArea.driven] interpolates it toward its target. + /// + /// Use this when the value is also needed directly, such as to size a + /// decoration painted behind the child. [maintainBottomViewPadding] governs + /// the bottom edge exactly as it does on the widget. + static EdgeInsets resolveInsets( + BuildContext context, { + bool left = true, + bool top = true, + bool right = true, + bool bottom = true, + EdgeInsets minimum = EdgeInsets.zero, + EdgeInsets margin = EdgeInsets.zero, + bool maintainBottomViewPadding = false, + }) { + final padding = MediaQuery.paddingOf(context); + final viewPadding = MediaQuery.viewPaddingOf(context); + final bottomSystem = maintainBottomViewPadding ? viewPadding.bottom : padding.bottom; + return EdgeInsets.only( + top: math.max(top ? padding.top : 0.0, minimum.top) + margin.top, + left: math.max(left ? padding.left : 0.0, minimum.left) + margin.left, + right: math.max(right ? padding.right : 0.0, minimum.right) + margin.right, + bottom: math.max(bottom ? bottomSystem : 0.0, minimum.bottom) + margin.bottom, + ); + } + + EdgeInsets _resolve(BuildContext context) => resolveInsets( + context, + left: left, + top: top, + right: right, + bottom: bottom, + minimum: minimum, + margin: margin, + maintainBottomViewPadding: maintainBottomViewPadding, + ); + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + + final consumed = MediaQuery.removePadding( + context: context, + removeLeft: left, + removeTop: top, + removeRight: right, + removeBottom: bottom, + child: child, + ); + + final listenable = _listenable; + if (listenable == null) { + return Padding(padding: _resolve(context), child: consumed); + } + + return ValueListenableBuilder( + valueListenable: listenable, + child: consumed, + builder: (context, t, child) { + final applied = EdgeInsets.lerp(_resolve(context), _to, t.clamp(0.0, 1.0))!; + return Padding(padding: applied, child: child); + }, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(FlagProperty('left', value: left, ifTrue: 'avoid left padding')) + ..add(FlagProperty('top', value: top, ifTrue: 'avoid top padding')) + ..add(FlagProperty('right', value: right, ifTrue: 'avoid right padding')) + ..add(FlagProperty('bottom', value: bottom, ifTrue: 'avoid bottom padding')) + ..add(DiagnosticsProperty('minimum', minimum, defaultValue: EdgeInsets.zero)) + ..add(DiagnosticsProperty('margin', margin, defaultValue: EdgeInsets.zero)) + ..add(DiagnosticsProperty('maintainBottomViewPadding', maintainBottomViewPadding, defaultValue: false)) + ..add(DiagnosticsProperty>('listenable', _listenable, defaultValue: null)) + ..add(DiagnosticsProperty('to', _to, defaultValue: EdgeInsets.zero)); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart b/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart index c7ee4e68..6a02d83b 100644 --- a/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart +++ b/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_core/stream_core.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_app_bar_theme.dart'; @@ -81,8 +80,9 @@ class StreamMediaViewerProps { this.showChrome = true, }); - /// The media content. Inset to fit between [header] and [footer] (plus - /// the top / bottom safe-area insets) so chrome never overlaps it. + /// The media content. Extends full-bleed behind floating chrome; when the + /// chrome is regular it is inset to fit between [header] and [footer] (plus + /// the top / bottom safe-area insets) so the chrome never overlaps it. final Widget child; /// The top chrome — typically a [StreamAppBar]. Slides off-screen @@ -96,7 +96,7 @@ class StreamMediaViewerProps { /// Whether the chrome (header / footer) is visible. /// /// When false, chrome slides off-screen and the background fades to - /// the immersive colour. The caller owns this state — typically a + /// the immersive color. The caller owns this state — typically a /// tap on the media toggles it. final bool showChrome; } @@ -146,9 +146,22 @@ class DefaultStreamMediaViewer extends StatelessWidget { return scoped; } + // Resolve the chrome's floating state: media is full-bleed behind floating + // chrome, inset under docked chrome. Resolved from the chrome style then the + // ambient StreamSurfaceStyle — not from a per-instance style on the header / + // footer widget, so pin the surfaceStyle on the media-viewer theme to keep the + // inset and the chrome in sync. + final fallbackFloating = context.streamSurfaceStyle.isFloating; + final headerFloating = effectiveAppBarStyle?.surfaceStyle?.isFloating ?? fallbackFloating; + final footerFloating = effectiveBottomAppBarStyle?.surfaceStyle?.isFloating ?? fallbackFloating; + final mediaQueryPadding = MediaQuery.paddingOf(context); - final headerInset = props.header?.let((it) => it.preferredSize.height + mediaQueryPadding.top) ?? 0.0; - final footerInset = props.footer?.let((it) => it.preferredSize.height + mediaQueryPadding.bottom) ?? 0.0; + + final header = props.header?.preferredSize; + final footer = props.footer?.preferredSize; + + final headerInset = (header == null || headerFloating) ? 0.0 : header.height + mediaQueryPadding.top; + final footerInset = (footer == null || footerFloating) ? 0.0 : footer.height + mediaQueryPadding.bottom; return AnimatedContainer( curve: Curves.easeInOut, 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..c48cc3c5 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,126 +1,80 @@ +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'; -import '../../theme/components/stream_bottom_app_bar_theme.dart'; import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_surface_style.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. /// /// [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 /// -/// The behaviour of each slot is resolved via a three-step priority chain: +/// The [appBar] slot resolves its surface style via a three-step chain: +/// +/// 1. Per-instance [appBarSurfaceStyle] on this widget. +/// 2. [StreamAppBarStyle.surfaceStyle] from the ambient [StreamAppBarTheme]. +/// 3. The ambient [StreamSurfaceStyle] ([StreamSurfaceStyle.floating] or +/// [StreamSurfaceStyle.regular]). +/// +/// The [bottom] slot holds an arbitrary widget — a composer, a nav bar, a bottom +/// app bar — so it reads no single component's theme. It resolves via: /// -/// 1. Per-instance [appBarBehavior] / [bottomBarBehavior] on this widget. -/// 2. [StreamAppBarStyle.behavior] / [StreamBottomAppBarStyle.behavior] -/// from the ambient component theme. -/// 3. The ambient [StreamAppStyle] enum value ([StreamAppStyle.floating] or -/// [StreamAppStyle.regular]). +/// 1. Per-instance [bottomSurfaceStyle] on this widget. +/// 2. The ambient [StreamSurfaceStyle]. /// -/// * [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. -/// * [StreamBottomAppBarBehavior.floating] — the body extends *behind* the bottom -/// widget; [StreamScaffoldInsets.bottomPadding] equals the measured height of -/// that widget. +/// * [StreamSurfaceStyle.floating] on the app bar — the body extends *behind* +/// the app bar; the body's `MediaQuery.padding.top` is set to the app-bar +/// height so content rests clear of the bar. +/// * [StreamSurfaceStyle.floating] on the bottom widget — the body extends *behind* the bottom +/// 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`. /// +/// This resolution does not read a `style` set directly on the slot widget. A bar +/// or composer that pins its own `surfaceStyle` can float while this scaffold +/// keeps the slot docked and publishes no inset. To keep the layout and chrome in +/// sync, set the matching [appBarSurfaceStyle] / [bottomSurfaceStyle] here, or +/// drive both from the ambient [StreamSurfaceStyle]. +/// /// ## 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 one via `Scaffold.of(context).openDrawer()` — for +/// example a chat SDK's channel-list header menu button. +/// +/// ## 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: +/// +/// ```dart +/// final padding = MediaQuery.paddingOf(context); +/// // apply padding.top / padding.bottom as the scroll view's padding … +/// ``` /// -/// ## InheritedWidget access +/// {@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,59 +85,105 @@ class StreamScaffold extends StatelessWidget { required this.body, this.bottom, this.drawer, + this.onDrawerChanged, this.endDrawer, - this.appBarBehavior, - this.bottomBarBehavior, + this.onEndDrawerChanged, + this.drawerScrimColor, + this.drawerEdgeDragWidth, + this.drawerEnableOpenDragGesture = true, + this.endDrawerEnableOpenDragGesture = true, + this.drawerDragStartBehavior = .start, + this.drawerBarrierDismissible = true, + this.appBarSurfaceStyle, + this.bottomSurfaceStyle, 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]). + /// When [bottomSurfaceStyle] is [StreamSurfaceStyle.floating] this widget + /// overlaps the body; otherwise it sits below it. + /// + /// A docked (regular) bottom rides above the on-screen keyboard rather than + /// being covered by it — intended for a composer, though a nav bar placed + /// here lifts with the keyboard too. 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; - /// Per-instance override for the app-bar floating behaviour. + /// 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; + + /// 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. /// - /// When null the value is resolved from [StreamAppBarStyle.behavior] + /// Defaults to `true`. + final bool drawerBarrierDismissible; + + /// Per-instance override for the app bar's surface style. + /// + /// When null the value is resolved from [StreamAppBarStyle.surfaceStyle] /// in the ambient [StreamAppBarTheme], falling back to the ambient - /// [StreamAppStyle]. - final StreamAppBarBehavior? appBarBehavior; + /// [StreamSurfaceStyle]. + final StreamSurfaceStyle? appBarSurfaceStyle; - /// Per-instance override for the bottom-bar floating behaviour. + /// Per-instance override for the [bottom] slot's surface style. /// - /// When null the value is resolved from - /// [StreamBottomAppBarStyle.behavior] in the ambient - /// [StreamBottomAppBarTheme], falling back to the ambient [StreamAppStyle]. - final StreamBottomAppBarBehavior? bottomBarBehavior; + /// When null the value falls back to the ambient [StreamSurfaceStyle]. The + /// bottom slot holds an arbitrary widget (a composer, a nav bar, a bottom app + /// bar), so — unlike [appBarSurfaceStyle] — it is not resolved from any single + /// component theme. + final StreamSurfaceStyle? bottomSurfaceStyle; - /// Background color of the scaffold. + /// The background color of the scaffold. /// /// Defaults to [StreamColorScheme.backgroundApp]. final Color? backgroundColor; @@ -193,94 +193,75 @@ 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 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 colorScheme = context.streamColorScheme; + + final surfaceStyle = context.streamSurfaceStyle; + final appBarStyle = context.streamAppBarTheme.style; + + var effectiveAppBarSurfaceStyle = appBarSurfaceStyle ?? appBarStyle?.surfaceStyle; + effectiveAppBarSurfaceStyle ??= surfaceStyle; + + final effectiveBottomSurfaceStyle = bottomSurfaceStyle ?? surfaceStyle; + + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundApp; + + final appBarFloating = effectiveAppBarSurfaceStyle == .floating; + final bottomFloating = effectiveBottomSurfaceStyle == .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.minWidth, super.maxWidth, + super.minHeight, super.maxHeight, 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,26 +274,23 @@ 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)); + // Tight constraints so a shrink-wrapping body fills the area instead of + // sizing to its intrinsic extent. layoutChild( _Slot.body, _BodyBoxConstraints( + minWidth: size.width, maxWidth: size.width, + minHeight: size.height, maxHeight: size.height, bottomHeight: bottomHeight, ), @@ -321,44 +299,82 @@ 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_app_bar.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_app_bar.dart index 4a275114..0cd8b016 100644 --- a/packages/stream_core_flutter/lib/src/components/toolbar/stream_app_bar.dart +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_app_bar.dart @@ -9,8 +9,9 @@ import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_floating_fade.dart'; import '../../theme/stream_theme_extensions.dart'; -import '../buttons/stream_button.dart'; import 'stream_toolbar.dart'; +import 'stream_toolbar_button.dart'; +import 'stream_toolbar_scope.dart'; /// A top-of-screen header for full-page surfaces in the Stream design system. /// @@ -52,7 +53,7 @@ import 'stream_toolbar.dart'; /// /// ## Theming /// -/// [StreamAppBar] uses [StreamAppBarThemeData] for default styling — colours, +/// [StreamAppBar] uses [StreamAppBarThemeData] for default styling — colors, /// padding, spacing, title/subtitle text styles, and per-slot button style /// propagation. Defaults are derived from [StreamColorScheme], /// [StreamTextTheme], and [StreamSpacing]. @@ -215,13 +216,12 @@ class DefaultStreamAppBar extends StatelessWidget { final icons = context.streamIcons; final spacing = context.streamSpacing; - final appStyle = context.streamTheme.appStyle; + final appBarTheme = context.streamAppBarTheme; - final style = context.streamAppBarTheme.style?.merge(props.style) ?? props.style; + final style = appBarTheme.style?.merge(props.style) ?? props.style; final defaults = _StreamAppBarStyleDefaults(context); - var effectiveBehavior = style?.behavior ?? defaults.behavior; - effectiveBehavior ??= appStyle.isFloating ? .floating : .regular; + final effectiveSurfaceStyle = style?.surfaceStyle ?? context.streamSurfaceStyle; final effectiveBackgroundColor = style?.backgroundColor ?? defaults.backgroundColor; final effectiveFloatingBackgroundColor = style?.floatingBackgroundColor ?? defaults.floatingBackgroundColor; @@ -248,13 +248,7 @@ class DefaultStreamAppBar extends StatelessWidget { }; final useCloseIcon = parentRoute is PageRoute && parentRoute.fullscreenDialog; final localizations = MaterialLocalizations.of(context); - leading = StreamButton.icon( - type: switch (effectiveBehavior) { - .floating => .outline, - .regular => .ghost, - }, - isFloating: effectiveBehavior == .floating, - style: .secondary, + leading = StreamToolbarButton.icon( tooltip: useCloseIcon ? localizations.closeButtonTooltip : localizations.backButtonTooltip, icon: Icon(useCloseIcon ? icons.xmark : backIcon), onPressed: Navigator.of(context).maybePop, @@ -346,7 +340,7 @@ class DefaultStreamAppBar extends StatelessWidget { } // The bar's bottom edge is intentionally a hairline border in the - // design system's `borderSubtle` colour — part of the bar's identity, + // design system's `borderSubtle` color — part of the bar's identity, // not a configurable divider. // // The outer [Semantics] keeps the bar's children grouped for screen @@ -355,19 +349,19 @@ class DefaultStreamAppBar extends StatelessWidget { // slot's semantics onto its own node — without it, a raw // [GestureDetector] in a slot would attach its action to the outer // container and collapse the bar into a single tappable focus stop. - return Semantics( + bar = Semantics( container: true, child: DecoratedBox( decoration: BoxDecoration( - color: switch (effectiveBehavior) { + color: switch (effectiveSurfaceStyle) { .floating => null, .regular => effectiveBackgroundColor, }, - gradient: switch (effectiveBehavior) { + gradient: switch (effectiveSurfaceStyle) { .floating => _getFloatingGradient(context, color: effectiveFloatingBackgroundColor), .regular => null, }, - border: switch (effectiveBehavior) { + border: switch (effectiveSurfaceStyle) { .floating => null, .regular => Border(bottom: BorderSide(color: context.streamColorScheme.borderSubtle)), }, @@ -375,6 +369,10 @@ class DefaultStreamAppBar extends StatelessWidget { child: Semantics(explicitChildNodes: true, child: bar), ), ); + + // Publish the resolved behaviour to the slots via a [StreamToolbarScope] so + // slot widgets ([StreamToolbarButton], header avatars, ...) match the bar. + return StreamToolbarScope(surfaceStyle: effectiveSurfaceStyle, child: bar); } LinearGradient _getFloatingGradient( diff --git a/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart index bf91a400..a49849a5 100644 --- a/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart @@ -6,8 +6,10 @@ import '../../theme/components/stream_button_theme.dart'; import '../../theme/primitives/stream_spacing.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/semantics/stream_text_theme.dart'; +import '../../theme/stream_floating_fade.dart'; import '../../theme/stream_theme_extensions.dart'; import 'stream_toolbar.dart'; +import 'stream_toolbar_scope.dart'; /// A bottom-of-screen toolbar for full-page surfaces in the Stream design /// system. @@ -53,7 +55,7 @@ import 'stream_toolbar.dart'; /// ## Theming /// /// [StreamBottomAppBar] uses [StreamBottomAppBarThemeData] for default -/// styling — colours, padding, spacing, title text style, and per-slot +/// styling — colors, padding, spacing, title text style, and per-slot /// button style propagation. Defaults are derived from [StreamColorScheme], /// [StreamTextTheme], and [StreamSpacing]. /// @@ -186,10 +188,15 @@ class DefaultStreamBottomAppBar extends StatelessWidget { Widget build(BuildContext context) { final spacing = context.streamSpacing; - final style = context.streamBottomAppBarTheme.style?.merge(props.style) ?? props.style; + final bottomAppBarTheme = context.streamBottomAppBarTheme; + + final style = bottomAppBarTheme.style?.merge(props.style) ?? props.style; final defaults = _StreamBottomAppBarStyleDefaults(context); + final effectiveSurfaceStyle = style?.surfaceStyle ?? context.streamSurfaceStyle; + final effectiveBackgroundColor = style?.backgroundColor ?? defaults.backgroundColor; + final effectiveFloatingBackgroundColor = style?.floatingBackgroundColor ?? defaults.floatingBackgroundColor; final effectivePadding = style?.padding ?? defaults.padding; final effectiveSpacing = style?.spacing ?? defaults.spacing; final effectiveTitleTextStyle = style?.titleTextStyle ?? defaults.titleTextStyle; @@ -271,8 +278,9 @@ class DefaultStreamBottomAppBar extends StatelessWidget { } // The bar's top edge is intentionally a hairline border in the design - // system's `borderSubtle` colour — part of the bar's identity, not a - // configurable divider. + // system's `borderSubtle` color — part of the bar's identity, not a + // configurable divider. When floating, the border is dropped and the bar + // fades into the content behind it via a gradient instead. // // The outer [Semantics] keeps the bar's children grouped for screen // readers, so leading, title, subtitle, and trailing aren't intermixed @@ -280,18 +288,49 @@ class DefaultStreamBottomAppBar extends StatelessWidget { // slot's semantics onto its own node — without it, a raw // [GestureDetector] in a slot would attach its action to the outer // container and collapse the bar into a single tappable focus stop. - return Semantics( + bar = Semantics( container: true, child: DecoratedBox( decoration: BoxDecoration( - color: effectiveBackgroundColor, - border: Border( - top: BorderSide(color: context.streamColorScheme.borderSubtle), - ), + color: switch (effectiveSurfaceStyle) { + .floating => null, + .regular => effectiveBackgroundColor, + }, + gradient: switch (effectiveSurfaceStyle) { + .floating => _getFloatingGradient(context, color: effectiveFloatingBackgroundColor), + .regular => null, + }, + border: switch (effectiveSurfaceStyle) { + .floating => null, + .regular => Border(top: BorderSide(color: context.streamColorScheme.borderSubtle)), + }, ), child: Semantics(explicitChildNodes: true, child: bar), ), ); + + // Publish the resolved behaviour to the slots via a [StreamToolbarScope] so + // slot widgets ([StreamToolbarButton], footer actions, ...) match the bar. + return StreamToolbarScope(surfaceStyle: effectiveSurfaceStyle, child: bar); + } + + LinearGradient _getFloatingGradient( + BuildContext context, { + required Color color, + }) { + // Compute the fraction of the total bar height occupied by the system + // safe area so the gradient is solid through the bottom inset and fades up + // through the toolbar zone above it. + final safeAreaBottom = props.primary ? MediaQuery.paddingOf(context).bottom : 0.0; + final totalHeight = safeAreaBottom + kStreamToolbarHeight; + final solidFraction = totalHeight > 0 ? safeAreaBottom / totalHeight : 0.0; + + return streamFloatingFadeLinearGradient( + color: color, + solidFraction: solidFraction, + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ); } } @@ -313,6 +352,9 @@ class _StreamBottomAppBarStyleDefaults extends StreamBottomAppBarStyle { @override Color get backgroundColor => _colorScheme.backgroundElevation1; + @override + Color get floatingBackgroundColor => _colorScheme.backgroundElevation0; + @override double get spacing => _spacing.sm; 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..ea618627 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 @@ -3,62 +3,97 @@ import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_badge_notification_theme.dart'; import '../../theme/components/stream_bottom_nav_bar_theme.dart'; +import '../../theme/primitives/stream_spacing.dart'; import '../../theme/stream_floating_fade.dart'; +import '../../theme/stream_surface_style.dart'; import '../../theme/stream_theme_extensions.dart'; +import '../common/stream_safe_area.dart'; + +/// Default height of [StreamBottomNavBar] per the Stream design system. +const double kStreamBottomNavBarHeight = 64; /// A single item in a [StreamBottomNavBar]. /// -/// Each item has an [icon] and [selectedIcon] widget (the latter is shown -/// when the item is active) and a text [label]. +/// Each item has an [icon] and a text [label]. An optional [selectedIcon] +/// replaces the icon while the item is active, and an optional [tooltip] is +/// shown on long-press or hover. /// /// The icon widgets are fully generic — callers are free to wrap them in /// badge overlays, unread indicators, or any other decorator. class StreamBottomNavBarItem { /// Creates a bottom nav bar item. const StreamBottomNavBarItem({ + this.key, required this.icon, - required this.selectedIcon, + this.selectedIcon, required this.label, + this.tooltip, + this.semanticsLabel, }); - /// The icon displayed when this item is inactive. + /// A key forwarded to the widget that renders this item. + /// + /// Give each item a stable key when the item list changes at runtime, so the + /// tiles keep their identity across rebuilds. + final Key? key; + + /// The icon displayed when this item is inactive — and while active when + /// [selectedIcon] is null. final Widget icon; /// The icon displayed when this item is active. - final Widget selectedIcon; + /// + /// Falls back to [icon] when null. + final Widget? selectedIcon; /// The text label shown below the icon. final String label; + + /// The text to display in a tooltip when the item is long-pressed (or + /// hovered on desktop / web). + /// + /// When null or empty, no tooltip is shown. + final String? tooltip; + + /// The label announced by accessibility tools, overriding [label]. + /// + /// Use this when the visible [label] doesn't fully describe the destination. + /// When null, [label] is announced. + final String? semanticsLabel; } -/// A bottom navigation bar for Stream surfaces that automatically adapts -/// between a floating pill style and a regular docked style based on the -/// ambient [StreamBottomNavBarBehavior]. +/// A bottom navigation bar for Stream surfaces. /// /// ## Floating style /// -/// When [StreamBottomNavBarBehavior.floating] is in effect, the bar renders as a -/// horizontally padded pill with a rounded background, a subtle box shadow, +/// When [StreamSurfaceStyle.floating] is in effect, the bar renders as a +/// horizontally padded pill with a rounded background, a subtle shadow, /// and a hairline border. It sits above the body content and is typically /// used with [StreamScaffold]'s floating bottom slot. /// /// ## Regular style /// -/// When [StreamBottomNavBarBehavior.regular] is in effect, the bar renders as a -/// standard docked bar with Stream colour and typography tokens. A hairline +/// When [StreamSurfaceStyle.regular] is in effect, the bar renders as a +/// standard docked bar with Stream color and typography tokens. A hairline /// `borderSubtle` top border separates it from the body. /// /// ## Behaviour resolution /// /// The effective behaviour is resolved in this priority order: -/// 1. The per-instance [behavior] parameter on this widget. -/// 2. [StreamBottomNavBarStyle.behavior] from the ambient -/// [StreamBottomNavBarTheme]. -/// 3. The ambient [StreamAppStyle] enum value. +/// 1. [StreamBottomNavBarStyle.surfaceStyle] — set per-instance via `style` or the +/// ambient [StreamBottomNavBarTheme]. +/// 2. The ambient [StreamSurfaceStyle] — floating maps to a floating pill, regular to +/// a docked bar. +/// +/// In a [StreamScaffold] `bottom` slot, drive floating through the ambient +/// [StreamSurfaceStyle] (or the scaffold's `bottomSurfaceStyle`) so the +/// scaffold reserves the matching body inset. Floating set only through +/// [StreamBottomNavBarTheme] floats the pill without that inset, so content can +/// slide under it. /// /// ## Theming /// -/// Item colours, icon size, label styles, border, and pill radius are resolved +/// Item colors, icon size, label styles, border, and pill radius are resolved /// from [StreamBottomNavBarStyle] — set per-instance via `style` or globally /// via [StreamBottomNavBarTheme], falling back to token-backed defaults. /// @@ -99,7 +134,6 @@ class StreamBottomNavBar extends StatelessWidget { required List items, required int currentIndex, required ValueChanged onTap, - StreamBottomNavBarBehavior? behavior, StreamBottomNavBarStyle? style, }) : assert(items.length >= 2, 'StreamBottomNavBar requires at least 2 items'), assert( @@ -111,7 +145,6 @@ class StreamBottomNavBar extends StatelessWidget { items: items, currentIndex: currentIndex, onTap: onTap, - behavior: behavior, style: style, ); @@ -141,7 +174,6 @@ class StreamBottomNavBarProps { required this.items, required this.currentIndex, required this.onTap, - this.behavior, this.style, }) : assert( currentIndex >= 0 && currentIndex < items.length, @@ -161,12 +193,6 @@ class StreamBottomNavBarProps { /// Called when the user taps a navigation item. final ValueChanged onTap; - /// Overrides the resolved [StreamBottomNavBarBehavior] for this instance only. - /// - /// When null the effective behaviour is resolved from the ambient themes; - /// see [StreamBottomNavBar] for the full resolution order. - final StreamBottomNavBarBehavior? behavior; - /// The visual style applied to this navigation bar. /// /// Resolution order per field: this [style] → ambient @@ -179,10 +205,10 @@ class StreamBottomNavBarProps { /// Renders the navigation bar with theming from [StreamBottomNavBarTheme] and /// serves as the default factory implementation in [StreamComponentFactory]. /// -/// Depending on the resolved [StreamBottomNavBarBehavior], the bar is either a +/// Depending on the resolved [StreamSurfaceStyle], the bar is either a /// docked bar (a solid surface with a hairline top border) or a floating pill /// (a rounded surface over a gradient fade). Both share the same tiles, each of -/// which animates its icon and label colour between the unselected and selected +/// which animates its icon and label color between the unselected and selected /// states on tap. /// /// See also: @@ -275,13 +301,14 @@ class _DefaultStreamBottomNavBarState extends State w }) { final localizations = MaterialLocalizations.of(context); - // Selected and unselected tiles share the animated colour tween; only the + // Selected and unselected tiles share the animated color tween; only the // label style is chosen per selection state. final colorTween = ColorTween(begin: unselectedItemColor, end: selectedItemColor); return [ for (var i = 0; i < _items.length; i++) _StreamNavTile( + key: _items[i].key, item: _items[i], animation: _animations[i], iconSize: iconSize, @@ -300,13 +327,10 @@ class _DefaultStreamBottomNavBarState extends State w assert(debugCheckHasMaterialLocalizations(context), 'MaterialLocalizations are required.'); assert(debugCheckHasMediaQuery(context), 'A MediaQuery ancestor is required.'); - final appStyle = context.streamTheme.appStyle; - final style = context.streamBottomNavBarTheme.style?.merge(widget.props.style) ?? widget.props.style; final defaults = _StreamBottomNavBarStyleDefaults(context); - var effectiveBehavior = widget.props.behavior ?? style?.behavior; - effectiveBehavior ??= appStyle.isFloating ? .floating : .regular; + final effectiveSurfaceStyle = style?.surfaceStyle ?? context.streamSurfaceStyle; final effectiveBackgroundColor = style?.backgroundColor ?? defaults.backgroundColor; final effectiveFloatingBackgroundColor = style?.floatingBackgroundColor ?? defaults.floatingBackgroundColor; @@ -317,11 +341,12 @@ class _DefaultStreamBottomNavBarState extends State w final effectiveUnselectedLabelStyle = style?.unselectedLabelStyle ?? defaults.unselectedLabelStyle; final effectiveBorderColor = style?.borderColor ?? defaults.borderColor; final effectiveBorderRadius = style?.borderRadius ?? defaults.borderRadius; + final effectiveElevation = style?.floatingElevation ?? defaults.floatingElevation; final tiles = StreamBadgeNotificationTheme( data: const StreamBadgeNotificationThemeData(size: StreamBadgeNotificationSize.xs), child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: kBottomNavigationBarHeight), + constraints: const BoxConstraints(minHeight: kStreamBottomNavBarHeight), // A transparent surface above the bar background so each tile can paint // its tap ripple. child: Material( @@ -345,17 +370,18 @@ class _DefaultStreamBottomNavBarState extends State w return Semantics( explicitChildNodes: true, - child: switch (effectiveBehavior) { - StreamBottomNavBarBehavior.regular => _RegularChrome( + child: switch (effectiveSurfaceStyle) { + StreamSurfaceStyle.regular => _RegularChrome( backgroundColor: effectiveBackgroundColor, borderColor: effectiveBorderColor, child: tiles, ), - StreamBottomNavBarBehavior.floating => _FloatingChrome( + StreamSurfaceStyle.floating => _FloatingChrome( pillColor: effectiveBackgroundColor, gradientColor: effectiveFloatingBackgroundColor, borderColor: effectiveBorderColor, borderRadius: effectiveBorderRadius, + elevation: effectiveElevation, child: tiles, ), }, @@ -363,10 +389,11 @@ class _DefaultStreamBottomNavBarState extends State w } } -// A single navigation tile: an icon above a label, both sharing a colour that +// A single navigation tile: an icon above a label, both sharing a color that // animates between the unselected and selected states. class _StreamNavTile extends StatelessWidget { const _StreamNavTile({ + super.key, required this.item, required this.animation, required this.iconSize, @@ -391,7 +418,7 @@ class _StreamNavTile extends StatelessWidget { final spacing = context.streamSpacing; final color = colorTween.evaluate(animation); - final Widget result = Semantics( + Widget tile = Semantics( selected: selected, button: true, container: true, @@ -410,7 +437,7 @@ class _StreamNavTile extends StatelessWidget { heightFactor: 1, child: IconTheme( data: IconThemeData(color: color, size: iconSize), - child: selected ? item.selectedIcon : item.icon, + child: selected ? (item.selectedIcon ?? item.icon) : item.icon, ), ), Align( @@ -418,7 +445,11 @@ class _StreamNavTile extends StatelessWidget { heightFactor: 1, child: MediaQuery.withClampedTextScaling( maxScaleFactor: 1, - child: Text(item.label, style: labelStyle.copyWith(color: color)), + child: Text( + item.label, + semanticsLabel: item.semanticsLabel, + style: labelStyle.copyWith(color: color), + ), ), ), ], @@ -430,7 +461,17 @@ class _StreamNavTile extends StatelessWidget { ), ); - return Expanded(child: result); + if (item.tooltip case final tooltip? when tooltip.isNotEmpty) { + tile = Tooltip( + message: tooltip, + preferBelow: false, + verticalOffset: iconSize + (labelStyle.fontSize ?? 0), + excludeFromSemantics: true, + child: tile, + ); + } + + return Expanded(child: tile); } } @@ -467,6 +508,7 @@ class _FloatingChrome extends StatelessWidget { required this.gradientColor, required this.borderColor, required this.borderRadius, + required this.elevation, required this.child, }); @@ -474,14 +516,22 @@ class _FloatingChrome extends StatelessWidget { final Color gradientColor; final Color borderColor; final BorderRadiusGeometry borderRadius; + final double elevation; 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({ + required double topInset, + required double bottomInset, + }) { + // The gradient spans the whole chrome — top margin, pill, and bottom inset. + // Keep it solid across the bottom inset and fade up through the pill into + // the content behind the bar. + // + // Approximate: kStreamBottomNavBarHeight is the tiles' minHeight, not the + // rendered height, so a taller wrapped label drifts the fade boundary + // slightly off the pill edge — fine in practice. + final totalHeight = topInset + kStreamBottomNavBarHeight + bottomInset; + final solidFraction = totalHeight > 0 ? bottomInset / totalHeight : 0.0; return streamFloatingFadeLinearGradient( color: gradientColor, @@ -495,25 +545,34 @@ class _FloatingChrome extends StatelessWidget { Widget build(BuildContext context) { final spacing = context.streamSpacing; + final minimum = EdgeInsets.only( + left: spacing.xl, + top: spacing.xl, + right: spacing.xl, + bottom: spacing.safeAreaBottom(), + ); + + final insets = StreamSafeArea.resolveInsets(context, top: false, minimum: minimum); + return DecoratedBox( - decoration: BoxDecoration(gradient: _buildGradient(context)), - child: SafeArea( + decoration: BoxDecoration( + gradient: _buildGradient( + topInset: insets.top, + bottomInset: insets.bottom, + ), + ), + child: StreamSafeArea( top: false, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: spacing.xl), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: pillColor, - borderRadius: borderRadius, - boxShadow: context.streamBoxShadow.elevation1, - ), - foregroundDecoration: BoxDecoration( - borderRadius: borderRadius, - border: Border.all(color: borderColor), - ), - child: child, + minimum: minimum, + child: Material( + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide(color: borderColor), ), + color: pillColor, + elevation: elevation, + clipBehavior: Clip.antiAlias, + child: child, ), ), ); @@ -533,6 +592,10 @@ class _StreamBottomNavBarStyleDefaults extends StreamBottomNavBarStyle { late final _colorScheme = _context.streamColorScheme; late final _textTheme = _context.streamTextTheme; late final _radius = _context.streamRadius; + late final _elevation = _context.streamElevation; + + @override + double get floatingElevation => _elevation.level3; @override Color get backgroundColor => _colorScheme.backgroundElevation1; @@ -559,5 +622,5 @@ class _StreamBottomNavBarStyleDefaults extends StreamBottomNavBarStyle { Color get borderColor => _colorScheme.borderSubtle; @override - BorderRadiusGeometry get borderRadius => BorderRadius.all(_radius.max); + BorderRadiusGeometry get borderRadius => .all(_radius.max); } diff --git a/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar_button.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar_button.dart new file mode 100644 index 00000000..b17859f7 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar_button.dart @@ -0,0 +1,173 @@ +import 'package:flutter/widgets.dart'; + +import '../../theme/components/stream_button_theme.dart'; +import '../buttons/stream_button.dart'; +import 'stream_toolbar_scope.dart'; + +/// A button that adapts to its enclosing Stream toolbar +/// ([StreamAppBar] / [StreamBottomAppBar]). +/// +/// A labelled action is outlined whether the bar is docked or floating (with +/// elevation when floating); an icon-only action ([StreamToolbarButton.icon]) +/// is outlined when floating and ghost when docked. +/// +/// Suitable for actions placed in a toolbar slot (the leading back affordance, a +/// trailing text action like _Edit_, footer actions) so they match the bar +/// without each caller styling them by hand. +/// +/// Mirrors [StreamButton]: the shape is determined by the presence of [child] — +/// a labelled button when [child] is non-null, and a circular icon-only button +/// (via [StreamToolbarButton.icon]) when it is null. Every [StreamButton] knob +/// is available: `isFloating` is set by the toolbar, and `type` is resolved from +/// the bar but can be overridden via [type]. +/// +/// {@tool snippet} +/// +/// Place a labelled action in a toolbar slot — it picks up the bar's floating +/// or docked look automatically: +/// +/// ```dart +/// StreamAppBar( +/// title: const Text('Profile'), +/// trailing: StreamToolbarButton( +/// onPressed: _startEditing, +/// child: const Text('Edit'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamToolbarButton.icon], for a circular icon-only button. +/// * [StreamToolbarScope], which provides the behaviour this button reads. +/// * [StreamButton], the underlying button this configures. +/// * [StreamAppBar] / [StreamBottomAppBar], the toolbars whose slots host this +/// button. +class StreamToolbarButton extends StatelessWidget { + /// Creates a labelled toolbar button displaying [child], optionally flanked + /// by [iconLeft] and/or [iconRight]. + const StreamToolbarButton({ + super.key, + this.type, + required Widget this.child, + this.iconLeft, + this.iconRight, + this.onPressed, + this.style = .secondary, + this.size = .medium, + this.isSelected, + this.autofocus = false, + this.themeStyle, + }) : tooltip = null; + + /// Creates a circular icon-only toolbar button displaying [icon]. + const StreamToolbarButton.icon({ + super.key, + this.type, + required Widget icon, + this.onPressed, + this.style = .secondary, + this.size = .medium, + this.isSelected, + this.autofocus = false, + this.tooltip, + this.themeStyle, + }) : child = null, + iconLeft = icon, + iconRight = null; + + /// The label rendered by the button. + /// + /// When null, the button renders as a circular icon-only button using + /// [iconLeft] as its sole icon (see [StreamToolbarButton.icon]). + final Widget? child; + + /// The icon rendered before [child], or the sole icon of an icon-only button. + final Widget? iconLeft; + + /// The icon rendered after [child]. Only honoured by a labelled button. + final Widget? iconRight; + + /// Called when the button is pressed. + /// + /// When null, the button is rendered disabled. + final VoidCallback? onPressed; + + /// The button shape. + /// + /// When null (the default), the toolbar resolves it from the enclosing bar. + final StreamButtonType? type; + + /// The color-scheme variant of the button. + /// + /// Defaults to [StreamButtonStyle.secondary]. + final StreamButtonStyle style; + + /// The size of the button. + /// + /// Defaults to [StreamButtonSize.medium]. + final StreamButtonSize size; + + /// Whether the button is in a selected state. + /// + /// When true, the button displays selected styling. + /// When false or null, the button is not selected. + final bool? isSelected; + + /// Whether the button should request focus when first mounted. + /// + /// Defaults to `false`. + final bool autofocus; + + /// Tooltip and accessibility label for the icon-only variant. + /// + /// Ignored by a labelled button, which derives its label from [child]. + final String? tooltip; + + /// Per-instance style overrides for this button. + /// + /// These properties take precedence over the inherited [StreamButtonTheme] + /// values for this specific button instance. + final StreamButtonThemeStyle? themeStyle; + + @override + Widget build(BuildContext context) { + final isFloating = StreamToolbarScope.of(context).isFloating; + + if (child case final child?) { + // A labelled action stays outlined whether docked or floating; floating + // only adds the elevation. + final effectiveType = type ?? .outline; + return StreamButton( + type: effectiveType, + isFloating: isFloating, + style: style, + size: size, + isSelected: isSelected, + autofocus: autofocus, + themeStyle: themeStyle, + iconLeft: iconLeft, + iconRight: iconRight, + onPressed: onPressed, + child: child, + ); + } + + // An icon-only action (e.g. the back affordance) is ghost when docked and + // outlined when floating. + final effectiveType = type ?? (isFloating ? .outline : .ghost); + return StreamButton.icon( + type: effectiveType, + isFloating: isFloating, + style: style, + size: size, + isSelected: isSelected, + autofocus: autofocus, + tooltip: tooltip, + themeStyle: themeStyle, + icon: iconLeft!, + onPressed: onPressed, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar_scope.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar_scope.dart new file mode 100644 index 00000000..753530df --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar_scope.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; + +import '../../theme/stream_surface_style.dart'; + +/// Publishes the resolved [StreamSurfaceStyle] of the enclosing toolbar to +/// its slots. +/// +/// A [StreamAppBar] / [StreamBottomAppBar] wraps its slots in a +/// [StreamToolbarScope] so slot widgets ([StreamToolbarButton], header avatars, +/// ...) can match the bar they sit in via [of]. +/// +/// See also: +/// +/// * [StreamSurfaceStyle], the value carried by this scope. +/// * [StreamToolbarButton], which reads it to style itself. +class StreamToolbarScope extends InheritedWidget { + /// Creates a [StreamToolbarScope] for the given resolved [surfaceStyle]. + const StreamToolbarScope({ + super.key, + required this.surfaceStyle, + required super.child, + }); + + /// The enclosing toolbar's resolved surface style. + final StreamSurfaceStyle surfaceStyle; + + /// The [StreamSurfaceStyle] of the nearest enclosing toolbar. + /// + /// Throws a [FlutterError] when called outside a [StreamAppBar] / + /// [StreamBottomAppBar] slot. Use [maybeOf] to get null instead. + static StreamSurfaceStyle of(BuildContext context) { + final result = maybeOf(context); + if (result != null) return result; + + throw FlutterError.fromParts([ + ErrorSummary( + 'StreamToolbarScope.of() called with a context that does not contain a ' + 'StreamToolbarScope.', + ), + ErrorDescription( + 'No StreamToolbarScope ancestor could be found starting from the ' + 'context that was passed to StreamToolbarScope.of(). A ' + 'StreamToolbarScope is published to its slots by a StreamAppBar / ' + 'StreamBottomAppBar.', + ), + ErrorHint( + 'To fix this, ensure this widget sits inside a Stream toolbar slot ' + '(leading / title / trailing).', + ), + context.describeElement('The context used was'), + ]); + } + + /// The [StreamSurfaceStyle] of the nearest enclosing toolbar, or null when + /// there is none. + static StreamSurfaceStyle? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.surfaceStyle; + } + + @override + bool updateShouldNotify(StreamToolbarScope oldWidget) => surfaceStyle != oldWidget.surfaceStyle; +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart index 6e504cee..3bf70713 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart @@ -1,29 +1,12 @@ import 'package:flutter/widgets.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import '../stream_surface_style.dart'; import '../stream_theme.dart'; import 'stream_button_theme.dart'; part 'stream_app_bar_theme.g.theme.dart'; -/// The floating or regular layout behaviour for a [StreamAppBar]. -/// -/// When null on [StreamAppBarStyle], the ambient [StreamAppStyle] is used -/// as a fallback — [StreamAppStyle.floating] maps to [floating] and -/// [StreamAppStyle.regular] maps to [regular]. -/// -/// See also: -/// -/// * [StreamAppBarStyle.behavior], which carries this value. -/// * [StreamAppStyle], the global app-wide style that acts as fallback. -enum StreamAppBarBehavior { - /// The app bar sits within the layout flow with a solid background. - regular, - - /// The app bar floats above the body with a translucent background. - floating, -} - /// Applies an app bar theme to descendant [StreamAppBar] widgets. /// /// Wrap a subtree with [StreamAppBarTheme] to override app bar styling. @@ -125,7 +108,7 @@ class StreamAppBarThemeData with _$StreamAppBarThemeData { /// Visual styling properties for a [StreamAppBar]. /// -/// Defines the appearance of the app bar — background colour, padding, +/// Defines the appearance of the app bar — background color, padding, /// inter-slot spacing, title and subtitle text styles, and per-slot button /// style propagation. /// @@ -154,7 +137,7 @@ class StreamAppBarThemeData with _$StreamAppBarThemeData { class StreamAppBarStyle with _$StreamAppBarStyle { /// Creates an app bar style with optional property overrides. const StreamAppBarStyle({ - this.behavior, + this.surfaceStyle, this.backgroundColor, this.floatingBackgroundColor, this.padding, @@ -165,23 +148,21 @@ class StreamAppBarStyle with _$StreamAppBarStyle { this.trailingStyle, }); - /// The floating or regular layout behaviour for this app bar. + /// The floating or regular surface style for this app bar. /// - /// When null the value falls back to the ambient [StreamAppStyle]: - /// [StreamAppStyle.floating] → [StreamAppBarBehavior.floating], - /// [StreamAppStyle.regular] → [StreamAppBarBehavior.regular]. - /// - /// Set this to override the global style for this component only, without - /// affecting other components. - final StreamAppBarBehavior? behavior; + /// When null the value falls back to the app-wide [StreamSurfaceStyle] set on + /// [StreamTheme]. + final StreamSurfaceStyle? surfaceStyle; - /// The background colour of the app bar. + /// The background color of the app bar when docked. + /// + /// Ignored when floating — the floating style uses [floatingBackgroundColor]. final Color? backgroundColor; - /// The background colour of the floating app bar. + /// The background color of the app bar when floating. final Color? floatingBackgroundColor; - /// The padding around the header's content row. + /// The padding around the bar's content row. final EdgeInsetsGeometry? padding; /// The horizontal space between the leading, heading, and trailing slots. diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart index afac78d2..071eb84a 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart @@ -99,7 +99,7 @@ mixin _$StreamAppBarStyle { } return StreamAppBarStyle( - behavior: t < 0.5 ? a.behavior : b.behavior, + surfaceStyle: t < 0.5 ? a.surfaceStyle : b.surfaceStyle, backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), floatingBackgroundColor: Color.lerp( a.floatingBackgroundColor, @@ -128,7 +128,7 @@ mixin _$StreamAppBarStyle { } StreamAppBarStyle copyWith({ - StreamAppBarBehavior? behavior, + StreamSurfaceStyle? surfaceStyle, Color? backgroundColor, Color? floatingBackgroundColor, EdgeInsetsGeometry? padding, @@ -141,7 +141,7 @@ mixin _$StreamAppBarStyle { final _this = (this as StreamAppBarStyle); return StreamAppBarStyle( - behavior: behavior ?? _this.behavior, + surfaceStyle: surfaceStyle ?? _this.surfaceStyle, backgroundColor: backgroundColor ?? _this.backgroundColor, floatingBackgroundColor: floatingBackgroundColor ?? _this.floatingBackgroundColor, @@ -166,7 +166,7 @@ mixin _$StreamAppBarStyle { } return copyWith( - behavior: other.behavior, + surfaceStyle: other.surfaceStyle, backgroundColor: other.backgroundColor, floatingBackgroundColor: other.floatingBackgroundColor, padding: other.padding, @@ -198,7 +198,7 @@ mixin _$StreamAppBarStyle { final _this = (this as StreamAppBarStyle); final _other = (other as StreamAppBarStyle); - return _other.behavior == _this.behavior && + return _other.surfaceStyle == _this.surfaceStyle && _other.backgroundColor == _this.backgroundColor && _other.floatingBackgroundColor == _this.floatingBackgroundColor && _other.padding == _this.padding && @@ -215,7 +215,7 @@ mixin _$StreamAppBarStyle { return Object.hash( runtimeType, - _this.behavior, + _this.surfaceStyle, _this.backgroundColor, _this.floatingBackgroundColor, _this.padding, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart index ea8e3f9b..9e54ed20 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart @@ -168,7 +168,7 @@ class StreamAvatarThemeData with _$StreamAvatarThemeData { /// The Material elevation applied to this avatar when it is floating. /// /// Used when [StreamAvatar.isFloating] (or [isFloating]) is true. - /// Falls back to the default elevation of `3`. + /// Falls back to the default elevation of `6`. final double? floatingElevation; /// Linearly interpolate between two [StreamAvatarThemeData] objects. diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.dart index c123c369..f06da103 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.dart @@ -1,30 +1,12 @@ import 'package:flutter/widgets.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import '../stream_surface_style.dart'; import '../stream_theme.dart'; import 'stream_button_theme.dart'; part 'stream_bottom_app_bar_theme.g.theme.dart'; -/// The floating or regular layout behaviour for a [StreamBottomAppBar] or -/// [StreamBottomNavBar]. -/// -/// When null on [StreamBottomAppBarStyle], the ambient [StreamAppStyle] is -/// used as a fallback — [StreamAppStyle.floating] maps to [floating] and -/// [StreamAppStyle.regular] maps to [regular]. -/// -/// See also: -/// -/// * [StreamBottomAppBarStyle.behavior], which carries this value. -/// * [StreamAppStyle], the global app-wide style that acts as fallback. -enum StreamBottomAppBarBehavior { - /// The bottom bar sits within the layout flow with a solid background. - regular, - - /// The bottom bar floats above the body with a translucent background. - floating, -} - /// Applies a bottom app bar theme to descendant [StreamBottomAppBar] widgets. /// /// Wrap a subtree with [StreamBottomAppBarTheme] to override bottom app bar @@ -129,7 +111,7 @@ class StreamBottomAppBarThemeData with _$StreamBottomAppBarThemeData { /// Visual styling properties for a [StreamBottomAppBar]. /// -/// Defines the appearance of the bottom app bar — background colour, +/// Defines the appearance of the bottom app bar — background color, /// padding, inter-slot spacing, title and subtitle text styles, and /// per-slot button style propagation. /// @@ -158,8 +140,9 @@ class StreamBottomAppBarThemeData with _$StreamBottomAppBarThemeData { class StreamBottomAppBarStyle with _$StreamBottomAppBarStyle { /// Creates a bottom app bar style with optional property overrides. const StreamBottomAppBarStyle({ - this.behavior, + this.surfaceStyle, this.backgroundColor, + this.floatingBackgroundColor, this.padding, this.spacing, this.titleTextStyle, @@ -168,19 +151,20 @@ class StreamBottomAppBarStyle with _$StreamBottomAppBarStyle { this.trailingStyle, }); - /// The floating or regular layout behaviour for this bottom bar. - /// - /// When null the value falls back to the ambient [StreamAppStyle]: - /// [StreamAppStyle.floating] → [StreamBottomAppBarBehavior.floating], - /// [StreamAppStyle.regular] → [StreamBottomAppBarBehavior.regular]. + /// The floating or regular surface style for this bottom bar. /// - /// Set this to override the global style for this component only, without - /// affecting other components. - final StreamBottomAppBarBehavior? behavior; + /// When null the value falls back to the app-wide [StreamSurfaceStyle] set on + /// [StreamTheme]. + final StreamSurfaceStyle? surfaceStyle; - /// The background colour of the bottom app bar. + /// The background color of the bottom app bar when docked. + /// + /// Ignored when floating — the floating style uses [floatingBackgroundColor]. final Color? backgroundColor; + /// The background color of the bottom app bar when floating. + final Color? floatingBackgroundColor; + /// The padding around the bar's content row. final EdgeInsetsGeometry? padding; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.g.theme.dart index 508ff261..14503987 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.g.theme.dart @@ -99,8 +99,13 @@ mixin _$StreamBottomAppBarStyle { } return StreamBottomAppBarStyle( - behavior: t < 0.5 ? a.behavior : b.behavior, + surfaceStyle: t < 0.5 ? a.surfaceStyle : b.surfaceStyle, backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + floatingBackgroundColor: Color.lerp( + a.floatingBackgroundColor, + b.floatingBackgroundColor, + t, + ), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), spacing: lerpDouble$(a.spacing, b.spacing, t), titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), @@ -123,8 +128,9 @@ mixin _$StreamBottomAppBarStyle { } StreamBottomAppBarStyle copyWith({ - StreamBottomAppBarBehavior? behavior, + StreamSurfaceStyle? surfaceStyle, Color? backgroundColor, + Color? floatingBackgroundColor, EdgeInsetsGeometry? padding, double? spacing, TextStyle? titleTextStyle, @@ -135,8 +141,10 @@ mixin _$StreamBottomAppBarStyle { final _this = (this as StreamBottomAppBarStyle); return StreamBottomAppBarStyle( - behavior: behavior ?? _this.behavior, + surfaceStyle: surfaceStyle ?? _this.surfaceStyle, backgroundColor: backgroundColor ?? _this.backgroundColor, + floatingBackgroundColor: + floatingBackgroundColor ?? _this.floatingBackgroundColor, padding: padding ?? _this.padding, spacing: spacing ?? _this.spacing, titleTextStyle: titleTextStyle ?? _this.titleTextStyle, @@ -158,8 +166,9 @@ mixin _$StreamBottomAppBarStyle { } return copyWith( - behavior: other.behavior, + surfaceStyle: other.surfaceStyle, backgroundColor: other.backgroundColor, + floatingBackgroundColor: other.floatingBackgroundColor, padding: other.padding, spacing: other.spacing, titleTextStyle: @@ -189,8 +198,9 @@ mixin _$StreamBottomAppBarStyle { final _this = (this as StreamBottomAppBarStyle); final _other = (other as StreamBottomAppBarStyle); - return _other.behavior == _this.behavior && + return _other.surfaceStyle == _this.surfaceStyle && _other.backgroundColor == _this.backgroundColor && + _other.floatingBackgroundColor == _this.floatingBackgroundColor && _other.padding == _this.padding && _other.spacing == _this.spacing && _other.titleTextStyle == _this.titleTextStyle && @@ -205,8 +215,9 @@ mixin _$StreamBottomAppBarStyle { return Object.hash( runtimeType, - _this.behavior, + _this.surfaceStyle, _this.backgroundColor, + _this.floatingBackgroundColor, _this.padding, _this.spacing, _this.titleTextStyle, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.dart index 2d0f11a9..63c20e1f 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.dart @@ -1,28 +1,11 @@ import 'package:flutter/widgets.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import '../stream_surface_style.dart'; import '../stream_theme.dart'; part 'stream_bottom_nav_bar_theme.g.theme.dart'; -/// The floating or regular layout behaviour for a [StreamBottomNavBar]. -/// -/// When null on [StreamBottomNavBarStyle], the ambient [StreamAppStyle] is used -/// as a fallback — [StreamAppStyle.floating] maps to [floating] and -/// [StreamAppStyle.regular] maps to [regular]. -/// -/// See also: -/// -/// * [StreamBottomNavBarStyle.behavior], which carries this value. -/// * [StreamAppStyle], the global app-wide style that acts as fallback. -enum StreamBottomNavBarBehavior { - /// The navigation bar sits within the layout flow with a solid background. - regular, - - /// The navigation bar floats above the body as a pill over a gradient fade. - floating, -} - /// Applies a bottom navigation bar theme to descendant [StreamBottomNavBar] /// widgets. /// @@ -32,7 +15,7 @@ enum StreamBottomNavBarBehavior { /// /// {@tool snippet} /// -/// Override the selected item colour for a specific subtree: +/// Override the selected item color for a specific subtree: /// /// ```dart /// StreamBottomNavBarTheme( @@ -123,7 +106,7 @@ class StreamBottomNavBarThemeData with _$StreamBottomNavBarThemeData { /// Visual styling properties for a [StreamBottomNavBar]. /// /// Defines the appearance of the navigation bar — the docked/floating -/// behaviour, background colours, per-item selected and unselected colours, +/// behaviour, background colors, per-item selected and unselected colors, /// icon size, item label styles, and the border and corner radius used by the /// floating pill. /// @@ -152,7 +135,8 @@ class StreamBottomNavBarThemeData with _$StreamBottomNavBarThemeData { class StreamBottomNavBarStyle with _$StreamBottomNavBarStyle { /// Creates a bottom navigation bar style with optional property overrides. const StreamBottomNavBarStyle({ - this.behavior, + this.surfaceStyle, + this.floatingElevation, this.backgroundColor, this.floatingBackgroundColor, this.selectedItemColor, @@ -164,23 +148,25 @@ class StreamBottomNavBarStyle with _$StreamBottomNavBarStyle { this.borderRadius, }); - /// The floating or regular layout behaviour for this navigation bar. + /// The floating or regular surface style for this navigation bar. /// - /// When null the value falls back to the ambient [StreamAppStyle]: - /// [StreamAppStyle.floating] → [StreamBottomNavBarBehavior.floating], - /// [StreamAppStyle.regular] → [StreamBottomNavBarBehavior.regular]. - final StreamBottomNavBarBehavior? behavior; + /// When null the value falls back to the app-wide [StreamSurfaceStyle] set on + /// [StreamTheme]. + final StreamSurfaceStyle? surfaceStyle; + + /// The elevation of the floating pill. + final double? floatingElevation; - /// The background colour of the docked bar and of the floating pill. + /// The background color of the docked bar and of the floating pill. final Color? backgroundColor; - /// The base colour of the floating gradient fade behind the pill. + /// The background color shown behind the pill when floating. final Color? floatingBackgroundColor; - /// The colour of the icon and label of the selected item. + /// The color of the icon and label of the selected item. final Color? selectedItemColor; - /// The colour of the icon and label of unselected items. + /// The color of the icon and label of unselected items. final Color? unselectedItemColor; /// The size of each item's icon. @@ -192,7 +178,7 @@ class StreamBottomNavBarStyle with _$StreamBottomNavBarStyle { /// The text style for the label of unselected items. final TextStyle? unselectedLabelStyle; - /// The colour of the docked bar's top border and the floating pill's border. + /// The color of the docked bar's top border and the floating pill's border. final Color? borderColor; /// The corner radius of the floating pill. diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.g.theme.dart index 15c863b8..84b652a9 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.g.theme.dart @@ -99,7 +99,12 @@ mixin _$StreamBottomNavBarStyle { } return StreamBottomNavBarStyle( - behavior: t < 0.5 ? a.behavior : b.behavior, + surfaceStyle: t < 0.5 ? a.surfaceStyle : b.surfaceStyle, + floatingElevation: lerpDouble$( + a.floatingElevation, + b.floatingElevation, + t, + ), backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), floatingBackgroundColor: Color.lerp( a.floatingBackgroundColor, @@ -137,7 +142,8 @@ mixin _$StreamBottomNavBarStyle { } StreamBottomNavBarStyle copyWith({ - StreamBottomNavBarBehavior? behavior, + StreamSurfaceStyle? surfaceStyle, + double? floatingElevation, Color? backgroundColor, Color? floatingBackgroundColor, Color? selectedItemColor, @@ -151,7 +157,8 @@ mixin _$StreamBottomNavBarStyle { final _this = (this as StreamBottomNavBarStyle); return StreamBottomNavBarStyle( - behavior: behavior ?? _this.behavior, + surfaceStyle: surfaceStyle ?? _this.surfaceStyle, + floatingElevation: floatingElevation ?? _this.floatingElevation, backgroundColor: backgroundColor ?? _this.backgroundColor, floatingBackgroundColor: floatingBackgroundColor ?? _this.floatingBackgroundColor, @@ -177,7 +184,8 @@ mixin _$StreamBottomNavBarStyle { } return copyWith( - behavior: other.behavior, + surfaceStyle: other.surfaceStyle, + floatingElevation: other.floatingElevation, backgroundColor: other.backgroundColor, floatingBackgroundColor: other.floatingBackgroundColor, selectedItemColor: other.selectedItemColor, @@ -207,7 +215,8 @@ mixin _$StreamBottomNavBarStyle { final _this = (this as StreamBottomNavBarStyle); final _other = (other as StreamBottomNavBarStyle); - return _other.behavior == _this.behavior && + return _other.surfaceStyle == _this.surfaceStyle && + _other.floatingElevation == _this.floatingElevation && _other.backgroundColor == _this.backgroundColor && _other.floatingBackgroundColor == _this.floatingBackgroundColor && _other.selectedItemColor == _this.selectedItemColor && @@ -225,7 +234,8 @@ mixin _$StreamBottomNavBarStyle { return Object.hash( runtimeType, - _this.behavior, + _this.surfaceStyle, + _this.floatingElevation, _this.backgroundColor, _this.floatingBackgroundColor, _this.selectedItemColor, diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart index b56d5a3c..d3d65a11 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart @@ -97,3 +97,22 @@ class StreamSpacing with _$StreamSpacing { double t, ) => _$StreamSpacing.lerp(a, b, t); } + +/// Platform-adaptive spacing values derived from the [StreamSpacing] scale. +extension StreamSpacingSafeArea on StreamSpacing { + /// The recommended minimum bottom inset for a floating surface, such as a + /// floating navigation pill or a message composer. + /// + /// Use it as the `minimum` bottom inset of a [StreamSafeArea] so the surface + /// keeps a comfortable gap above the bottom of the screen, while a larger + /// system inset (home indicator, navigation bar) is honored as-is. + /// + /// Adapts to [platform], defaulting to the current platform. + double safeAreaBottom({TargetPlatform? platform}) { + platform ??= defaultTargetPlatform; + return switch (platform) { + .iOS || .macOS => xxl, + _ => xxxl, + }; + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 38fc5970..7deb520d 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -30,6 +30,7 @@ mixin _$StreamColorScheme { } return StreamColorScheme.raw( + brightness: t < 0.5 ? a.brightness : b.brightness, brand: t < 0.5 ? a.brand : b.brand, chrome: t < 0.5 ? a.chrome : b.chrome, accentPrimary: Color.lerp(a.accentPrimary, b.accentPrimary, t)!, @@ -164,6 +165,7 @@ mixin _$StreamColorScheme { } StreamColorScheme copyWith({ + Brightness? brightness, StreamColorSwatch? brand, StreamColorSwatch? chrome, Color? accentPrimary, @@ -222,6 +224,7 @@ mixin _$StreamColorScheme { final _this = (this as StreamColorScheme); return StreamColorScheme.raw( + brightness: brightness ?? _this.brightness, brand: brand ?? _this.brand, chrome: chrome ?? _this.chrome, accentPrimary: accentPrimary ?? _this.accentPrimary, @@ -297,6 +300,7 @@ mixin _$StreamColorScheme { } return copyWith( + brightness: other.brightness, brand: other.brand, chrome: other.chrome, accentPrimary: other.accentPrimary, @@ -367,7 +371,8 @@ mixin _$StreamColorScheme { final _this = (this as StreamColorScheme); final _other = (other as StreamColorScheme); - return _other.brand == _this.brand && + return _other.brightness == _this.brightness && + _other.brand == _this.brand && _other.chrome == _this.chrome && _other.accentPrimary == _this.accentPrimary && _other.accentSuccess == _this.accentSuccess && @@ -429,6 +434,7 @@ mixin _$StreamColorScheme { return Object.hashAll([ runtimeType, + _this.brightness, _this.brand, _this.chrome, _this.accentPrimary, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_app_style.dart b/packages/stream_core_flutter/lib/src/theme/stream_app_style.dart deleted file mode 100644 index 29eb3aa5..00000000 --- a/packages/stream_core_flutter/lib/src/theme/stream_app_style.dart +++ /dev/null @@ -1,57 +0,0 @@ -/// The overall visual style of a Stream-powered app. -/// -/// [StreamAppStyle] is a coarse, app-wide switch. Set it once on [StreamTheme] -/// to make every Stream component default to either a grounded *regular* look -/// or an airy *floating* look: -/// -/// * [regular] — app bar and bottom bar sit within the layout flow; the -/// message composer is docked at the bottom edge. -/// * [floating] — app bar and bottom bar float above the body with translucent -/// backgrounds; the message composer floats above the keyboard. -/// -/// Individual components can override this default by setting their own -/// behaviour on their component-specific theme style (e.g. -/// [StreamAppBarStyle.behavior] for [StreamAppBar], or -/// [StreamBottomAppBarStyle.behavior] for [StreamBottomAppBar]). -/// -/// ## Resolution order (high → low priority) -/// -/// 1. Component theme style field (e.g. `StreamAppBarStyle.behavior`) -/// 2. This [StreamAppStyle] enum value (the global fallback) -/// -/// {@tool snippet} -/// -/// Apply a floating style to the whole app: -/// -/// ```dart -/// StreamTheme( -/// data: StreamTheme(appStyle: StreamAppStyle.floating), -/// child: MyApp(), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamTheme], which holds this value. -/// * [StreamAppBarStyle.behavior], the per-component override for the app bar. -/// * [StreamBottomAppBarStyle.behavior], the per-component override for the bottom bar. -enum StreamAppStyle { - /// All components default to a grounded, regularly-positioned layout. - /// - /// App bar and bottom bar sit within the layout flow. The message composer - /// is docked at the bottom edge. - regular, - - /// All components default to an airy, floating layout. - /// - /// App bar and bottom bar float above the body with translucent backgrounds. - /// The message composer floats above the keyboard. - floating; - - /// Whether this style is the floating variant. - /// - /// Convenience getter used by components when mapping to their own - /// component-specific behavior enums without hard-coding enum names. - bool get isFloating => this == StreamAppStyle.floating; -} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_surface_style.dart b/packages/stream_core_flutter/lib/src/theme/stream_surface_style.dart new file mode 100644 index 00000000..b28d4b4a --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/stream_surface_style.dart @@ -0,0 +1,29 @@ +/// The presentation of a Stream surface. +/// +/// A component's own style (e.g. [StreamAppBarStyle.surfaceStyle]) takes +/// precedence over the app-wide default on [StreamTheme]. +/// +/// {@tool snippet} +/// +/// Make the whole app floating: +/// +/// ```dart +/// ThemeData(extensions: [StreamTheme(surfaceStyle: StreamSurfaceStyle.floating)]) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamTheme], which holds the app-wide default. +/// * [StreamAppBarStyle.surfaceStyle], which overrides it for the app bar. +/// * [StreamBottomAppBarStyle.surfaceStyle], which overrides it for the bottom bar. +enum StreamSurfaceStyle { + /// A docked surface: opaque, taking its own space in the layout. + regular, + + /// A floating surface: translucent, hovering over the content it covers. + floating; + + /// Whether this is the [floating] variant. + bool get isFloating => this == .floating; +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index c04f7ac9..3e2ba81a 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -47,7 +47,7 @@ import 'primitives/stream_typography.dart'; import 'semantics/stream_box_shadow.dart'; import 'semantics/stream_color_scheme.dart'; import 'semantics/stream_text_theme.dart'; -import 'stream_app_style.dart'; +import 'stream_surface_style.dart'; part 'stream_theme.g.theme.dart'; @@ -108,7 +108,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { factory StreamTheme({ Brightness? brightness, TargetPlatform? platform, - StreamAppStyle? appStyle, + StreamSurfaceStyle? surfaceStyle, StreamElevation? elevation, StreamIcons? icons, StreamRadius? radius, @@ -178,7 +178,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { boxShadow ??= isDark ? StreamBoxShadow.dark() : StreamBoxShadow.light(); // Components - appStyle ??= StreamAppStyle.regular; + surfaceStyle ??= StreamSurfaceStyle.regular; appBarTheme ??= const StreamAppBarThemeData(); audioWaveformTheme ??= const StreamAudioWaveformThemeData(); avatarTheme ??= const StreamAvatarThemeData(); @@ -220,7 +220,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { return .raw( // ignore: deprecated_member_use_from_same_package brightness: effectiveBrightness, - appStyle: appStyle, + surfaceStyle: surfaceStyle, elevation: elevation, icons: icons, radius: radius, @@ -283,7 +283,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { const StreamTheme.raw({ @Deprecated('Use colorScheme.brightness instead') this.brightness = Brightness.light, - required this.appStyle, + required this.surfaceStyle, required this.elevation, required this.icons, required this.radius, @@ -370,8 +370,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { @Deprecated('Use colorScheme.brightness instead') final Brightness brightness; - /// The app style for this theme. - final StreamAppStyle appStyle; + /// The app-wide surface style every component falls back to. + final StreamSurfaceStyle surfaceStyle; /// The Material elevation values for this theme. final StreamElevation elevation; @@ -537,7 +537,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { return StreamTheme.raw( // ignore: deprecated_member_use_from_same_package brightness: brightness, - appStyle: appStyle, + surfaceStyle: surfaceStyle, elevation: elevation, icons: icons, radius: radius, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart index 09a3a2ba..ce7ab227 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -13,7 +13,7 @@ mixin _$StreamTheme on ThemeExtension { @override ThemeExtension copyWith({ Brightness? brightness, - StreamAppStyle? appStyle, + StreamSurfaceStyle? surfaceStyle, StreamElevation? elevation, StreamIcons? icons, StreamRadius? radius, @@ -70,7 +70,7 @@ mixin _$StreamTheme on ThemeExtension { return StreamTheme.raw( brightness: brightness ?? _this.brightness, - appStyle: appStyle ?? _this.appStyle, + surfaceStyle: surfaceStyle ?? _this.surfaceStyle, elevation: elevation ?? _this.elevation, icons: icons ?? _this.icons, radius: radius ?? _this.radius, @@ -150,7 +150,7 @@ mixin _$StreamTheme on ThemeExtension { return StreamTheme.raw( brightness: t < 0.5 ? _this.brightness : other.brightness, - appStyle: t < 0.5 ? _this.appStyle : other.appStyle, + surfaceStyle: t < 0.5 ? _this.surfaceStyle : other.surfaceStyle, elevation: StreamElevation.lerp(_this.elevation, other.elevation, t)!, icons: StreamIcons.lerp(_this.icons, other.icons, t)!, radius: StreamRadius.lerp(_this.radius, other.radius, t)!, @@ -369,7 +369,7 @@ mixin _$StreamTheme on ThemeExtension { final _other = (other as StreamTheme); return _other.brightness == _this.brightness && - _other.appStyle == _this.appStyle && + _other.surfaceStyle == _this.surfaceStyle && _other.elevation == _this.elevation && _other.icons == _this.icons && _other.radius == _this.radius && @@ -431,7 +431,7 @@ mixin _$StreamTheme on ThemeExtension { return Object.hashAll([ runtimeType, _this.brightness, - _this.appStyle, + _this.surfaceStyle, _this.elevation, _this.icons, _this.radius, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart index 3c475500..802fb66b 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -45,6 +45,7 @@ import 'primitives/stream_typography.dart'; import 'semantics/stream_box_shadow.dart'; import 'semantics/stream_color_scheme.dart'; import 'semantics/stream_text_theme.dart'; +import 'stream_surface_style.dart'; import 'stream_theme.dart'; /// Extension on [BuildContext] for convenient access to [StreamTheme]. @@ -92,6 +93,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamElevation] from the current theme. StreamElevation get streamElevation => streamTheme.elevation; + /// Returns the app-wide [StreamSurfaceStyle] from the current theme. + StreamSurfaceStyle get streamSurfaceStyle => streamTheme.surfaceStyle; + /// Returns the [StreamBoxShadow] from the current theme. StreamBoxShadow get streamBoxShadow => streamTheme.boxShadow; diff --git a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_dark.png b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_dark.png index 4c8ab592..983b08d9 100644 Binary files a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_dark.png and b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_dark.png differ diff --git a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_light.png b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_light.png index 6130be6f..cca923cd 100644 Binary files a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_light.png and b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_group_shadow_light.png differ diff --git a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_shadow_light.png b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_shadow_light.png index fe5ca712..54b6bc54 100644 Binary files a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_shadow_light.png and b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_shadow_light.png differ diff --git a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_dark.png b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_dark.png index 5ff0e365..c8d0a3fe 100644 Binary files a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_dark.png and b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_dark.png differ diff --git a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_light.png b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_light.png index 058b89fd..846ab9d9 100644 Binary files a/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_light.png and b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_stack_shadow_light.png differ diff --git a/packages/stream_core_flutter/test/components/common/stream_safe_area_test.dart b/packages/stream_core_flutter/test/components/common/stream_safe_area_test.dart new file mode 100644 index 00000000..0315a0ac --- /dev/null +++ b/packages/stream_core_flutter/test/components/common/stream_safe_area_test.dart @@ -0,0 +1,355 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + group('StreamSafeArea.resolveInsets', () { + Future resolve( + WidgetTester tester, { + EdgeInsets padding = EdgeInsets.zero, + EdgeInsets viewPadding = EdgeInsets.zero, + required EdgeInsets Function(BuildContext) compute, + }) async { + late EdgeInsets result; + await tester.pumpWidget( + MediaQuery( + data: MediaQueryData(padding: padding, viewPadding: viewPadding), + child: Builder( + builder: (context) { + result = compute(context); + return const SizedBox(); + }, + ), + ), + ); + return result; + } + + testWidgets('adds the margin on top of the system inset per edge', (tester) async { + final insets = await resolve( + tester, + padding: const EdgeInsets.only(top: 44, bottom: 34, left: 10, right: 12), + viewPadding: const EdgeInsets.only(top: 44, bottom: 34, left: 10, right: 12), + compute: (context) => StreamSafeArea.resolveInsets(context, margin: const EdgeInsets.all(24)), + ); + + expect(insets, const EdgeInsets.only(top: 68, bottom: 58, left: 34, right: 36)); + }); + + testWidgets('drops the system inset on disabled edges but keeps the margin', (tester) async { + final insets = await resolve( + tester, + padding: const EdgeInsets.only(top: 44, bottom: 34), + viewPadding: const EdgeInsets.only(top: 44, bottom: 34), + compute: (context) => StreamSafeArea.resolveInsets(context, top: false, margin: const EdgeInsets.all(24)), + ); + + // top edge ignores the 44 status bar but still gets the 24 margin. + expect(insets.top, 24); + expect(insets.bottom, 58); + }); + + testWidgets('measures the bottom from viewPadding when maintainBottomViewPadding is set', (tester) async { + final insets = await resolve( + tester, + // A keyboard has consumed the bottom padding (34 -> 0, the default) but not viewPadding. + viewPadding: const EdgeInsets.only(bottom: 34), + compute: (context) => StreamSafeArea.resolveInsets( + context, + margin: const EdgeInsets.all(24), + maintainBottomViewPadding: true, + ), + ); + + // viewPadding.bottom (34) + 24, not padding.bottom (0) + 24. + expect(insets.bottom, 58); + }); + + testWidgets('floors each edge at the minimum, which a larger inset absorbs', (tester) async { + final insets = await resolve( + tester, + padding: const EdgeInsets.only(top: 44, left: 10), + viewPadding: const EdgeInsets.only(top: 44, left: 10, bottom: 12), + compute: (context) => StreamSafeArea.resolveInsets( + context, + minimum: const EdgeInsets.all(32), + ), + ); + + // top: max(44, 32) = 44 (inset wins); left: max(10, 32) = 32 (floor wins); + // bottom: max(12, 32) = 32; right: max(0, 32) = 32. + expect(insets, const EdgeInsets.only(top: 44, left: 32, right: 32, bottom: 32)); + }); + + testWidgets('adds the margin on top of the minimum', (tester) async { + final insets = await resolve( + tester, + viewPadding: const EdgeInsets.only(bottom: 10), + compute: (context) => StreamSafeArea.resolveInsets( + context, + minimum: const EdgeInsets.only(bottom: 32), + margin: const EdgeInsets.only(bottom: 8), + ), + ); + + // max(10, 32) + 8 = 40. + expect(insets.bottom, 40); + }); + + testWidgets('measures the bottom from padding by default', (tester) async { + final insets = await resolve( + tester, + padding: const EdgeInsets.only(bottom: 10), + viewPadding: const EdgeInsets.only(bottom: 50), + compute: (context) => StreamSafeArea.resolveInsets( + context, + margin: const EdgeInsets.all(24), + ), + ); + + // padding.bottom (10) + 24, not viewPadding.bottom (50) + 24. + expect(insets.bottom, 34); + }); + }); + + group('StreamSafeArea widget', () { + const childKey = ValueKey('child'); + + /// The gap between the [StreamSafeArea]'s edges and its child, i.e. the + /// insets it actually applied. + EdgeInsets appliedInsets(WidgetTester tester) { + final outer = tester.getRect(find.byType(StreamSafeArea)); + final child = tester.getRect(find.byKey(childKey)); + return EdgeInsets.fromLTRB( + child.left - outer.left, + child.top - outer.top, + outer.right - child.right, + outer.bottom - child.bottom, + ); + } + + Future pump( + WidgetTester tester, + StreamSafeArea widget, { + EdgeInsets padding = EdgeInsets.zero, + EdgeInsets viewPadding = EdgeInsets.zero, + TextDirection textDirection = TextDirection.ltr, + }) { + return tester.pumpWidget( + Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(padding: padding, viewPadding: viewPadding), + child: widget, + ), + ), + ); + } + + testWidgets('insets its child by the system inset plus the margin', (tester) async { + await pump( + tester, + const StreamSafeArea( + top: false, + margin: EdgeInsets.all(24), + child: SizedBox.expand(key: childKey), + ), + padding: const EdgeInsets.only(bottom: 34, left: 10), + viewPadding: const EdgeInsets.only(bottom: 34, left: 10), + ); + + // top: 0 (disabled) + 24; left: 10 + 24; bottom: 34 + 24. Matches resolveInsets. + expect(appliedInsets(tester), const EdgeInsets.only(top: 24, left: 34, right: 24, bottom: 58)); + }); + + testWidgets('insets physical edges the same way under RTL', (tester) async { + const widget = StreamSafeArea( + margin: EdgeInsets.only(left: 4, right: 8), + child: SizedBox.expand(key: childKey), + ); + const insets = EdgeInsets.only(left: 10, right: 30); + + await pump(tester, widget, padding: insets, viewPadding: insets); + final ltr = appliedInsets(tester); + + await pump(tester, widget, padding: insets, viewPadding: insets, textDirection: TextDirection.rtl); + final rtl = appliedInsets(tester); + + // Physical left = 10 + 4, right = 30 + 8; RTL does not swap them. + expect(ltr, const EdgeInsets.only(left: 14, right: 38)); + expect(rtl, ltr); + }); + + testWidgets('keeps the bottom gap when a keyboard collapses the padding', (tester) async { + await pump( + tester, + const StreamSafeArea( + margin: EdgeInsets.all(24), + maintainBottomViewPadding: true, + child: SizedBox.expand(key: childKey), + ), + // A keyboard has collapsed padding.bottom to 0 (the default); viewPadding.bottom stays 34. + viewPadding: const EdgeInsets.only(bottom: 34), + ); + + // maintainBottomViewPadding keeps 34 + 24. + expect(appliedInsets(tester).bottom, 58); + }); + + testWidgets('driven interpolates the inset from full toward "to" by the listenable', (tester) async { + Future drivenAt(double t, {EdgeInsets to = EdgeInsets.zero}) async { + await pump( + tester, + StreamSafeArea.driven( + listenable: AlwaysStoppedAnimation(t), + to: to, + top: false, + minimum: const EdgeInsets.only(bottom: 40), + child: const SizedBox.expand(key: childKey), + ), + padding: const EdgeInsets.only(bottom: 20), + viewPadding: const EdgeInsets.only(bottom: 20), + ); + return appliedInsets(tester); + } + + // bottom = max(20, 40) = 40 at t=0, toward `to` (default zero) as t -> 1. + expect((await drivenAt(0)).bottom, 40); + expect((await drivenAt(1)).bottom, 0); + expect((await drivenAt(0.5)).bottom, 20); + // A non-zero target: lerp(40, 10, 0.5) = 25. + expect((await drivenAt(0.5, to: const EdgeInsets.only(bottom: 10))).bottom, 25); + }); + + testWidgets('driven removes the avoided inset from the child MediaQuery', (tester) async { + late double childBottom; + await pump( + tester, + StreamSafeArea.driven( + listenable: const AlwaysStoppedAnimation(0), + top: false, + child: Builder( + builder: (context) { + childBottom = MediaQuery.paddingOf(context).bottom; + return const SizedBox.expand(key: childKey); + }, + ), + ), + padding: const EdgeInsets.only(bottom: 30), + viewPadding: const EdgeInsets.only(bottom: 30), + ); + + // The bottom inset is consumed, so a descendant sees no bottom padding. + expect(childBottom, 0); + }); + + testWidgets('driven rebuilds as its listenable changes', (tester) async { + final t = ValueNotifier(0); + addTearDown(t.dispose); + + await pump( + tester, + StreamSafeArea.driven( + listenable: t, + top: false, + minimum: const EdgeInsets.only(bottom: 40), + child: const SizedBox.expand(key: childKey), + ), + viewPadding: const EdgeInsets.only(bottom: 20), + ); + + // max(20, 40) = 40 at t=0. + expect(appliedInsets(tester).bottom, 40); + + t.value = 1; + await tester.pump(); + + // Rebuilt and collapsed to `to` (zero) at t=1. + expect(appliedInsets(tester).bottom, 0); + }); + + testWidgets('default removes the avoided inset from the child MediaQuery', (tester) async { + late double childBottom; + await pump( + tester, + StreamSafeArea( + top: false, + child: Builder( + builder: (context) { + childBottom = MediaQuery.paddingOf(context).bottom; + return const SizedBox.expand(key: childKey); + }, + ), + ), + padding: const EdgeInsets.only(bottom: 30), + viewPadding: const EdgeInsets.only(bottom: 30), + ); + + expect(childBottom, 0); + }); + + testWidgets('nested does not inset the same edge twice', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(padding: EdgeInsets.all(20), viewPadding: EdgeInsets.all(20)), + child: StreamSafeArea( + child: StreamSafeArea(child: SizedBox.expand(key: childKey)), + ), + ), + ), + ); + + // The outer consumes the 20 inset; the inner sees 0 → 20 total, not 40. + final child = tester.getRect(find.byKey(childKey)); + expect(child.left, 20); + expect(child.top, 20); + }); + + testWidgets('updates when the MediaQuery padding changes', (tester) async { + const widget = StreamSafeArea(child: SizedBox.expand(key: childKey)); + + await pump(tester, widget, padding: const EdgeInsets.all(20), viewPadding: const EdgeInsets.all(20)); + expect(appliedInsets(tester), const EdgeInsets.all(20)); + + await pump( + tester, + widget, + padding: const EdgeInsets.only(left: 40, top: 10), + viewPadding: const EdgeInsets.only(left: 40, top: 10), + ); + expect(appliedInsets(tester), const EdgeInsets.only(left: 40, top: 10)); + }); + + testWidgets('does not crash at zero area', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(padding: EdgeInsets.all(20)), + child: Center( + child: SizedBox.shrink(child: StreamSafeArea(child: Placeholder())), + ), + ), + ), + ); + + expect(tester.getSize(find.byType(StreamSafeArea)), Size.zero); + }); + + testWidgets('debugFillProperties surfaces the avoided edges', (tester) async { + final widget = StreamSafeArea(right: false, bottom: false, child: Container()); + final properties = DiagnosticPropertiesBuilder(); + widget.debugFillProperties(properties); + + bool has(String flag) => + properties.properties.any((DiagnosticsNode n) => n is FlagProperty && n.toString() == flag); + expect(has('avoid left padding'), isTrue); + expect(has('avoid top padding'), isTrue); + expect(has('avoid right padding'), isFalse); + expect(has('avoid bottom padding'), isFalse); + }); + }); +} diff --git a/packages/stream_core_flutter/test/components/media_viewer/stream_media_viewer_test.dart b/packages/stream_core_flutter/test/components/media_viewer/stream_media_viewer_test.dart new file mode 100644 index 00000000..fe27e700 --- /dev/null +++ b/packages/stream_core_flutter/test/components/media_viewer/stream_media_viewer_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +const _childKey = Key('media-child'); + +class _FakeBar extends StatelessWidget implements PreferredSizeWidget { + const _FakeBar({required this.height}); + + final double height; + + @override + Size get preferredSize => Size.fromHeight(height); + + @override + Widget build(BuildContext context) => SizedBox(height: height); +} + +Widget _wrap( + Widget child, { + StreamMediaViewerThemeData? viewerTheme, + StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, +}) { + final scoped = viewerTheme == null ? child : StreamMediaViewerTheme(data: viewerTheme, child: child); + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + home: scoped, + ); +} + +EdgeInsets _mediaInset(WidgetTester tester) { + final padding = tester.widget( + find.ancestor(of: find.byKey(_childKey), matching: find.byType(AnimatedPadding)), + ); + return padding.padding.resolve(TextDirection.ltr); +} + +void main() { + group('StreamMediaViewer chrome layout', () { + testWidgets('follows a regular app style → media is inset between the bars', (tester) async { + await tester.pumpWidget( + _wrap( + StreamMediaViewer( + header: const _FakeBar(height: 56), + footer: const _FakeBar(height: 72), + child: const SizedBox.expand(key: _childKey), + ), + ), + ); + + final inset = _mediaInset(tester); + expect(inset.top, 56.0); + expect(inset.bottom, 72.0); + }); + + testWidgets('regular chrome adds the device inset to the bar heights', (tester) async { + await tester.pumpWidget( + _wrap( + Builder( + builder: (context) { + final base = MediaQuery.of(context); + return MediaQuery( + data: base.copyWith(padding: const EdgeInsets.only(top: 44, bottom: 34)), + child: StreamMediaViewer( + header: const _FakeBar(height: 56), + footer: const _FakeBar(height: 72), + child: const SizedBox.expand(key: _childKey), + ), + ); + }, + ), + ), + ); + + // Docked chrome clears the system insets: bar height + device padding. + final inset = _mediaInset(tester); + expect(inset.top, 56.0 + 44.0); + expect(inset.bottom, 72.0 + 34.0); + }); + + testWidgets('follows a floating app style → media is full-bleed behind the chrome', (tester) async { + await tester.pumpWidget( + _wrap( + StreamMediaViewer( + header: const _FakeBar(height: 56), + footer: const _FakeBar(height: 72), + child: const SizedBox.expand(key: _childKey), + ), + surfaceStyle: .floating, + ), + ); + + final inset = _mediaInset(tester); + expect(inset.top, 0.0); + expect(inset.bottom, 0.0); + }); + + testWidgets('a regular chrome override insets the media even under a floating app style', (tester) async { + await tester.pumpWidget( + _wrap( + StreamMediaViewer( + header: const _FakeBar(height: 56), + footer: const _FakeBar(height: 72), + child: const SizedBox.expand(key: _childKey), + ), + surfaceStyle: .floating, + viewerTheme: const StreamMediaViewerThemeData( + appBarStyle: StreamAppBarStyle(surfaceStyle: .regular), + bottomAppBarStyle: StreamBottomAppBarStyle(surfaceStyle: .regular), + ), + ), + ); + + final inset = _mediaInset(tester); + expect(inset.top, 56.0); + expect(inset.bottom, 72.0); + }); + }); +} 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..e7f03c13 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 @@ -1,73 +1,134 @@ +import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_core_flutter/core.dart'; -Widget _withStreamTheme(Widget child, {StreamAppStyle appStyle = StreamAppStyle.regular}) { +Widget _withStreamTheme(Widget child, {StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular}) { return MaterialApp( - theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), home: child, ); } -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, + StreamSurfaceStyle? appBarSurfaceStyle, + StreamSurfaceStyle? bottomSurfaceStyle, + PreferredSizeWidget? appBar, + Widget? bottom, + bool resizeToAvoidBottomInset = true, + EdgeInsets devicePadding = EdgeInsets.zero, + EdgeInsets viewInsets = EdgeInsets.zero, + StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, +}) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + 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( + appBarSurfaceStyle: appBarSurfaceStyle, + bottomSurfaceStyle: bottomSurfaceStyle, + 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 the full drawer configuration to the underlying Scaffold', (tester) async { const drawer = Drawer(key: ValueKey('drawer')); const endDrawer = Drawer(key: ValueKey('end-drawer')); + const scrimColor = Color(0xFF123456); await tester.pumpWidget( _withStreamTheme( - const StreamScaffold( + StreamScaffold( drawer: drawer, endDrawer: endDrawer, - body: _InsetsProbe(), + onDrawerChanged: (_) {}, + onEndDrawerChanged: (_) {}, + drawerScrimColor: scrimColor, + drawerEdgeDragWidth: 42, + drawerEnableOpenDragGesture: false, + endDrawerEnableOpenDragGesture: false, + drawerDragStartBehavior: DragStartBehavior.down, + drawerBarrierDismissible: false, + restorationId: 'scaffold-restoration', + body: const 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)); + // Callbacks are compared by presence: a mis-wire to null would fail here. + expect(scaffold.onDrawerChanged, isNotNull); + expect(scaffold.onEndDrawerChanged, isNotNull); + expect(scaffold.drawerScrimColor, scrimColor); + expect(scaffold.drawerEdgeDragWidth, 42); + expect(scaffold.drawerEnableOpenDragGesture, isFalse); + expect(scaffold.endDrawerEnableOpenDragGesture, isFalse); + expect(scaffold.drawerDragStartBehavior, DragStartBehavior.down); + expect(scaffold.drawerBarrierDismissible, isFalse); + expect(scaffold.restorationId, 'scaffold-restoration'); }); testWidgets('places a regular bottom widget below the body instead of in bottomNavigationBar', (tester) async { @@ -87,92 +148,77 @@ 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(), + appBarSurfaceStyle: StreamSurfaceStyle.floating, + 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('keeps a floating bottom widget out of the bottomNavigationBar slot', (tester) async { await tester.pumpWidget( _withStreamTheme( const StreamScaffold( - bottomBarBehavior: StreamBottomAppBarBehavior.floating, - bottom: SizedBox(height: bottomHeight), - body: _InsetsProbe(), + bottomSurfaceStyle: StreamSurfaceStyle.floating, + bottom: SizedBox(height: 64), + body: SizedBox(), ), ), ); + // The floating bottom overlaps the body from within it (see + // _StreamScaffoldBody), never via the Scaffold's bottomNavigationBar slot. 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, + bottomSurfaceStyle: StreamSurfaceStyle.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 { - await tester.pumpWidget( - _withStreamTheme( - const StreamScaffold( - bottomBarBehavior: StreamBottomAppBarBehavior.floating, - body: _InsetsProbe(), - ), - ), + testWidgets('injects no bottom inset when floating is set but no bottom widget is provided', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + bottomSurfaceStyle: StreamSurfaceStyle.floating, + devicePadding: const EdgeInsets.only(bottom: 34), + body: _InsetProbe(captured), ); - final scaffold = tester.widget(find.byType(Scaffold)); - expect(scaffold.extendBody, isFalse); - expect(find.text('top:0.0 bottom:0.0'), findsOneWidget); + // With no bottom widget the floating layout is skipped, so the body keeps + // the raw device inset — nothing is added. + expect(captured.padding!.bottom, 34); }); }); - group('behavior resolution', () { - testWidgets('falls back to the component theme when no instance behavior is set', (tester) async { + group('surfaceStyle resolution', () { + testWidgets('falls back to the component theme when no instance surfaceStyle is set', (tester) async { await tester.pumpWidget( _withStreamTheme( const StreamAppBarTheme( - data: StreamAppBarThemeData(style: StreamAppBarStyle(behavior: StreamAppBarBehavior.floating)), + data: StreamAppBarThemeData(style: StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating)), child: StreamScaffold( appBar: PreferredSize(preferredSize: Size.fromHeight(kToolbarHeight), child: SizedBox()), body: SizedBox(), @@ -185,7 +231,7 @@ void main() { expect(scaffold.extendBodyBehindAppBar, isTrue); }); - testWidgets('falls back to the ambient StreamAppStyle when neither instance nor theme set a behavior', ( + testWidgets('falls back to the ambient StreamSurfaceStyle when neither instance nor theme set a surfaceStyle', ( tester, ) async { await tester.pumpWidget( @@ -194,7 +240,7 @@ void main() { appBar: PreferredSize(preferredSize: Size.fromHeight(kToolbarHeight), child: SizedBox()), body: SizedBox(), ), - appStyle: StreamAppStyle.floating, + surfaceStyle: StreamSurfaceStyle.floating, ), ); @@ -203,6 +249,285 @@ 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, and the + // docked bottom owns the home-indicator inset (stripped from the body). + expect(captured.padding!.top, 0); + expect(captured.padding!.bottom, 0); + }); + + testWidgets('floating app bar → padding.top = measured app-bar height', (tester) async { + final captured = _CapturedInsets(); + await _pumpStreamScaffold( + tester, + appBarSurfaceStyle: StreamSurfaceStyle.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, + bottomSurfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.floating, + bottomSurfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.floating, + appBar: StreamAppBar( + automaticallyImplyLeading: false, + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.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, + appBarSurfaceStyle: StreamSurfaceStyle.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('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, + bottomSurfaceStyle: StreamSurfaceStyle.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, + bottomSurfaceStyle: StreamSurfaceStyle.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, moreOrLessEquals(surfaceHeight - keyboard, epsilon: 0.5)); + }); + + testWidgets('floating bottom bar stays at the surface bottom when resizeToAvoidBottomInset is false', ( + tester, + ) async { + const barKey = ValueKey('bar'); + await _pumpStreamScaffold( + tester, + bottomSurfaceStyle: StreamSurfaceStyle.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('a regular bottom rides above the keyboard rather than behind it', (tester) async { + const barKey = ValueKey('bar'); + const keyboard = 300.0; + + await _pumpStreamScaffold( + tester, + appBarSurfaceStyle: StreamSurfaceStyle.floating, + appBar: _rawAppBar(), + bottomSurfaceStyle: StreamSurfaceStyle.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, moreOrLessEquals(surfaceHeight - keyboard, epsilon: 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_app_bar_floating.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_app_bar_floating.png new file mode 100644 index 00000000..88c7f248 Binary files /dev/null and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_app_bar_floating.png differ diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_app_bar_regular.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_app_bar_regular.png new file mode 100644 index 00000000..07b142b2 Binary files /dev/null and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_app_bar_regular.png differ 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 cafa4f4c..6ee7b2e7 100644 Binary files a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_floating.png and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_floating.png differ diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_regular.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_regular.png index 90387646..af655fb8 100644 Binary files a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_regular.png and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_bottom_nav_bar_regular.png differ diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_golden_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_golden_test.dart index 52dde5d0..6479826c 100644 --- a/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_golden_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_golden_test.dart @@ -112,7 +112,7 @@ void main() { StreamAppBar( automaticallyImplyLeading: false, primary: false, - style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), title: const Text('Details'), ), ), @@ -123,7 +123,7 @@ void main() { StreamAppBar( automaticallyImplyLeading: false, primary: false, - style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), leading: StreamButton.icon( icon: const Icon(Icons.chevron_left), style: StreamButtonStyle.secondary, @@ -156,7 +156,7 @@ void main() { StreamAppBar( automaticallyImplyLeading: false, primary: false, - style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), title: const Text('Details'), ), brightness: Brightness.dark, @@ -168,7 +168,7 @@ void main() { StreamAppBar( automaticallyImplyLeading: false, primary: false, - style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), leading: StreamButton.icon( icon: const Icon(Icons.chevron_left), style: StreamButtonStyle.secondary, diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart index e2bd5b51..6f782354 100644 --- a/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart @@ -116,7 +116,7 @@ void main() { body: StreamAppBar( automaticallyImplyLeading: false, primary: false, - style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), title: const Text('Title'), ), ), @@ -140,7 +140,7 @@ void main() { testWidgets('floating: true uses outline button type for auto-implied leading', (tester) async { await tester.pumpWidget( _withStreamTheme( - const _LauncherScreen(appBarStyle: StreamAppBarStyle(behavior: StreamAppBarBehavior.floating)), + const _LauncherScreen(appBarStyle: StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating)), ), ); await tester.tap(find.text('Open')); @@ -160,41 +160,136 @@ void main() { }); }); - group('StreamAppBar semantics', () { - testWidgets('auto-implied back button carries the localized Back tooltip', (tester) async { - await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + group('StreamAppBar slot behaviour', () { + // A slot resolves its behaviour from the ambient StreamToolbarScope — the + // value downstream SDKs read to drive floating-aware slots. The bar + // publishes its resolved behaviour so a `style` handed only to the bar + // still reaches its slots. + StreamSurfaceStyle? captured; + + Widget probe() { + return Builder( + builder: (context) { + captured = StreamToolbarScope.of(context); + return const SizedBox.shrink(); + }, + ); + } - expect(find.byTooltip('Back'), findsOneWidget); - }); + tearDown(() => captured = null); - testWidgets('auto-implied close button carries the localized Close tooltip', (tester) async { - await tester.pumpWidget(_withStreamTheme(const _LauncherScreen(fullscreenDialog: true))); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + testWidgets('slot resolves floating when style is passed to the bar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + body: StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ); - expect(find.byTooltip('Close'), findsOneWidget); + expect(captured, StreamSurfaceStyle.floating); }); - testWidgets('title is marked as a heading by default', (tester) async { - final handle = tester.ensureSemantics(); + testWidgets('slot resolves regular by default', (tester) async { await tester.pumpWidget( _withStreamTheme( Scaffold( appBar: StreamAppBar( automaticallyImplyLeading: false, title: const Text('Title'), + trailing: probe(), ), ), ), ); - final data = tester.getSemantics(find.text('Title')).getSemanticsData(); - expect(data.label, equals('Title')); - expect(data.flagsCollection.isHeader, isTrue); + expect(captured, StreamSurfaceStyle.regular); + }); - handle.dispose(); + testWidgets('slot resolves floating from the ambient app style', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: StreamSurfaceStyle.floating)]), + home: Scaffold( + body: StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.floating); + }); + + testWidgets('slot resolves floating from an ambient app bar theme', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamAppBarTheme( + data: const StreamAppBarThemeData( + style: StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + ), + child: Scaffold( + body: StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.floating); + }); + + testWidgets('bar style overrides the ambient app bar theme for slots', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamAppBarTheme( + data: const StreamAppBarThemeData( + style: StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + ), + child: Scaffold( + appBar: StreamAppBar( + automaticallyImplyLeading: false, + style: const StreamAppBarStyle(surfaceStyle: StreamSurfaceStyle.regular), + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.regular); + }); + }); + + group('StreamAppBar semantics', () { + testWidgets('auto-implied back button carries the localized Back tooltip', (tester) async { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Back'), findsOneWidget); + }); + + testWidgets('auto-implied close button carries the localized Close tooltip', (tester) async { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen(fullscreenDialog: true))); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Close'), findsOneWidget); }); testWidgets('title names the route on Android', (tester) async { diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_golden_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_golden_test.dart new file mode 100644 index 00000000..5c3191e6 --- /dev/null +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_golden_test.dart @@ -0,0 +1,118 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +const _kBarWidth = 390.0; + +void main() { + group('StreamBottomAppBar Golden Tests', () { + goldenTest( + 'renders regular variants', + fileName: 'stream_bottom_app_bar_regular', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'light', + child: _buildBarInTheme(_bar(StreamSurfaceStyle.regular)), + ), + GoldenTestScenario( + name: 'dark', + child: _buildBarInTheme(_bar(StreamSurfaceStyle.regular), brightness: Brightness.dark), + ), + ], + ), + ); + + goldenTest( + 'renders floating variants', + fileName: 'stream_bottom_app_bar_floating', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'light', + child: _buildFloatingBarInTheme(_bar(StreamSurfaceStyle.floating)), + ), + GoldenTestScenario( + name: 'dark', + child: _buildFloatingBarInTheme(_bar(StreamSurfaceStyle.floating), brightness: Brightness.dark), + ), + ], + ), + ); + }); +} + +Widget _bar(StreamSurfaceStyle surfaceStyle) { + final floating = surfaceStyle.isFloating; + final type = floating ? StreamButtonType.outline : StreamButtonType.ghost; + return StreamBottomAppBar( + style: StreamBottomAppBarStyle(surfaceStyle: surfaceStyle), + leading: StreamButton.icon( + icon: const Icon(Icons.share), + type: type, + isFloating: floating, + onPressed: () {}, + ), + title: const Text('1 of 9'), + subtitle: const Text('Tap to share'), + trailing: StreamButton.icon( + icon: const Icon(Icons.grid_view), + type: type, + isFloating: floating, + onPressed: () {}, + ), + ); +} + +Widget _buildBarInTheme(Widget bar, {Brightness brightness = Brightness.light}) { + final streamTheme = StreamTheme(brightness: brightness); + return Theme( + data: ThemeData(brightness: brightness, extensions: [streamTheme]), + child: Builder( + builder: (context) => Material( + color: StreamTheme.of(context).colorScheme.backgroundApp, + child: SizedBox(width: _kBarWidth, child: bar), + ), + ), + ); +} + +/// Wraps a floating [StreamBottomAppBar] over a content gradient so the upward +/// fade is clearly visible in the snapshot. +Widget _buildFloatingBarInTheme(Widget bar, {Brightness brightness = Brightness.light}) { + final streamTheme = StreamTheme(brightness: brightness); + return Theme( + data: ThemeData(brightness: brightness, extensions: [streamTheme]), + child: Builder( + builder: (context) { + final colorScheme = StreamTheme.of(context).colorScheme; + return SizedBox( + width: _kBarWidth, + height: kStreamToolbarHeight * 3, + child: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.backgroundApp, + colorScheme.accentPrimary.withAlpha(0x40), + ], + ), + ), + ), + ), + Positioned(bottom: 0, left: 0, right: 0, child: bar), + ], + ), + ); + }, + ), + ); +} diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart index a3d71c69..6ad54595 100644 --- a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart @@ -11,17 +11,6 @@ Widget _withStreamTheme(Widget child) { void main() { group('StreamBottomAppBar slots', () { - testWidgets('renders title only — no leading or trailing', (tester) async { - await tester.pumpWidget( - _withStreamTheme( - Scaffold(bottomNavigationBar: StreamBottomAppBar(title: const Text('1 of 9'))), - ), - ); - - expect(find.text('1 of 9'), findsOneWidget); - expect(find.byType(StreamButton), findsNothing); - }); - testWidgets('renders title, subtitle, leading and trailing', (tester) async { await tester.pumpWidget( _withStreamTheme( @@ -165,4 +154,139 @@ void main() { }, ); }); + + group('StreamBottomAppBar floating', () { + testWidgets('regular uses solid background and top border', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold(bottomNavigationBar: StreamBottomAppBar(title: const Text('Title'))), + ), + ); + + final decoratedBox = tester.widget( + find + .descendant( + of: find.byType(StreamBottomAppBar), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.color, isNotNull); + expect(decoration.gradient, isNull); + expect(decoration.border, isNotNull); + }); + + testWidgets('floating uses gradient and no border', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + bottomNavigationBar: StreamBottomAppBar( + style: const StreamBottomAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + title: const Text('Title'), + ), + ), + ), + ); + + final decoratedBox = tester.widget( + find + .descendant( + of: find.byType(StreamBottomAppBar), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.color, isNull); + expect(decoration.gradient, isA()); + expect(decoration.border, isNull); + }); + }); + + group('StreamBottomAppBar slot behaviour', () { + // A slot resolves its behaviour from the ambient StreamToolbarScope. + // The bar publishes its resolved behaviour so a `style` handed only to + // the bar still reaches its slots. + StreamSurfaceStyle? captured; + + Widget probe() { + return Builder( + builder: (context) { + captured = StreamToolbarScope.of(context); + return const SizedBox.shrink(); + }, + ); + } + + tearDown(() => captured = null); + + testWidgets('slot resolves floating when style is passed to the bar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + bottomNavigationBar: StreamBottomAppBar( + style: const StreamBottomAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.floating); + }); + + testWidgets('slot resolves regular by default', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + bottomNavigationBar: StreamBottomAppBar( + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.regular); + }); + + testWidgets('slot resolves floating from the ambient app style', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: StreamSurfaceStyle.floating)]), + home: Scaffold( + bottomNavigationBar: StreamBottomAppBar( + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.floating); + }); + + testWidgets('bar style overrides the ambient app bar theme for slots', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomAppBarTheme( + data: const StreamBottomAppBarThemeData( + style: StreamBottomAppBarStyle(surfaceStyle: StreamSurfaceStyle.floating), + ), + child: Scaffold( + bottomNavigationBar: StreamBottomAppBar( + style: const StreamBottomAppBarStyle(surfaceStyle: StreamSurfaceStyle.regular), + title: const Text('Title'), + trailing: probe(), + ), + ), + ), + ), + ); + + expect(captured, StreamSurfaceStyle.regular); + }); + }); } diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_golden_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_golden_test.dart index 6c831bae..635f7bb6 100644 --- a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_golden_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_golden_test.dart @@ -68,7 +68,7 @@ Widget _regularBar() { items: _items, currentIndex: 0, onTap: (_) {}, - behavior: StreamBottomNavBarBehavior.regular, + style: const StreamBottomNavBarStyle(surfaceStyle: .regular), ); } @@ -77,7 +77,7 @@ Widget _floatingBar() { items: _items, currentIndex: 1, onTap: (_) {}, - behavior: StreamBottomNavBarBehavior.floating, + style: const StreamBottomNavBarStyle(surfaceStyle: .floating), ); } diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_test.dart index 504e303b..931284cd 100644 --- a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_test.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_core_flutter/core.dart'; @@ -15,9 +17,9 @@ const _items = [ ), ]; -Widget _withStreamTheme(Widget child, {StreamAppStyle appStyle = StreamAppStyle.regular}) { +Widget _withStreamTheme(Widget child, {StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular}) { return MaterialApp( - theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), home: Scaffold(body: child), ); } @@ -63,69 +65,137 @@ void main() { ); }); - group('regular behavior', () { - testWidgets('renders a docked bar with a top border and no gradient', (tester) async { + group('item options', () { + testWidgets('selectedIcon falls back to icon when null', (tester) async { await tester.pumpWidget( _withStreamTheme( StreamBottomNavBar( - items: _items, currentIndex: 0, onTap: (_) {}, - behavior: StreamBottomNavBarBehavior.regular, + items: const [ + StreamBottomNavBarItem(icon: Icon(Icons.home), label: 'Home'), + StreamBottomNavBarItem( + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', + ), + ], ), ), ); - final decoratedBox = tester.widget( - find - .descendant( - of: find.byType(StreamBottomNavBar), - matching: find.byType(DecoratedBox), - ) - .first, + // Item 0 is selected but sets no selectedIcon → its plain icon is shown. + expect(find.byIcon(Icons.home), findsOneWidget); + }); + + testWidgets('renders a Tooltip when an item sets tooltip', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + currentIndex: 0, + onTap: (_) {}, + items: const [ + StreamBottomNavBarItem(icon: Icon(Icons.home), label: 'Home', tooltip: 'Go home'), + StreamBottomNavBarItem(icon: Icon(Icons.search), label: 'Search'), + ], + ), + ), ); - final decoration = decoratedBox.decoration as BoxDecoration; - expect(decoration.gradient, isNull); - expect(decoration.border, isNotNull); - expect(find.text('Chats'), findsOneWidget); + + expect(find.byTooltip('Go home'), findsOneWidget); }); - }); - group('floating behavior', () { - testWidgets('renders a pill container instead of a BottomNavigationBar', (tester) async { + testWidgets('shows no tooltip for an empty tooltip string', (tester) async { await tester.pumpWidget( _withStreamTheme( StreamBottomNavBar( - items: _items, currentIndex: 0, onTap: (_) {}, - behavior: StreamBottomNavBarBehavior.floating, + items: const [ + StreamBottomNavBarItem(icon: Icon(Icons.home), label: 'Home', tooltip: ''), + StreamBottomNavBarItem(icon: Icon(Icons.search), label: 'Search'), + ], ), ), ); - expect(find.byType(BottomNavigationBar), findsNothing); - expect(find.text('Chats'), findsOneWidget); + expect(find.byType(Tooltip), findsNothing); }); - testWidgets('invokes onTap with the tapped index', (tester) async { - int? tappedIndex; + testWidgets('announces semanticsLabel in place of the visible label', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + currentIndex: 0, + onTap: (_) {}, + items: const [ + StreamBottomNavBarItem(icon: Icon(Icons.home), label: 'Home', semanticsLabel: 'Home screen'), + StreamBottomNavBarItem(icon: Icon(Icons.search), label: 'Search'), + ], + ), + ), + ); + + final tile = find.semantics.byPredicate((n) => n.label.contains('Tab 1 of 2')); + final label = tile.evaluate().single.label; + + // The override is announced, and the visible "Home" label is excluded — so + // it isn't spoken a second time (only the one inside "Home screen" remains). + expect(label, contains('Home screen')); + expect(RegExp('Home').allMatches(label), hasLength(1)); + + handle.dispose(); + }); + + testWidgets('forwards an item key to its tile', (tester) async { + const homeKey = ValueKey('home-tile'); await tester.pumpWidget( _withStreamTheme( StreamBottomNavBar( - items: _items, currentIndex: 0, - onTap: (index) => tappedIndex = index, - behavior: StreamBottomNavBarBehavior.floating, + onTap: (_) {}, + items: const [ + StreamBottomNavBarItem(key: homeKey, icon: Icon(Icons.home), label: 'Home'), + StreamBottomNavBarItem(icon: Icon(Icons.search), label: 'Search'), + ], ), ), ); - await tester.tap(find.text('Saved')); + expect(find.byKey(homeKey), findsOneWidget); + }); + }); - expect(tappedIndex, equals(1)); + group('regular surfaceStyle', () { + testWidgets('renders a docked bar with a top border and no gradient', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (_) {}, + style: const StreamBottomNavBarStyle(surfaceStyle: .regular), + ), + ), + ); + + final decoratedBox = tester.widget( + find + .descendant( + of: find.byType(StreamBottomNavBar), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.gradient, isNull); + expect(decoration.border, isNotNull); + expect(find.text('Chats'), findsOneWidget); }); + }); + group('floating surfaceStyle', () { testWidgets('renders a gradient background', (tester) async { await tester.pumpWidget( _withStreamTheme( @@ -133,7 +203,7 @@ void main() { items: _items, currentIndex: 0, onTap: (_) {}, - behavior: StreamBottomNavBarBehavior.floating, + style: const StreamBottomNavBarStyle(surfaceStyle: .floating), ), ), ); @@ -151,13 +221,13 @@ void main() { }); }); - group('behavior resolution', () { - testWidgets('resolves behavior from StreamBottomNavBarTheme', (tester) async { + group('surfaceStyle resolution', () { + testWidgets('resolves surfaceStyle from StreamBottomNavBarTheme', (tester) async { await tester.pumpWidget( _withStreamTheme( StreamBottomNavBarTheme( data: const StreamBottomNavBarThemeData( - style: StreamBottomNavBarStyle(behavior: StreamBottomNavBarBehavior.floating), + style: StreamBottomNavBarStyle(surfaceStyle: .floating), ), child: StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), ), @@ -172,25 +242,25 @@ void main() { _withStreamTheme( StreamBottomAppBarTheme( data: const StreamBottomAppBarThemeData( - style: StreamBottomAppBarStyle(behavior: StreamBottomAppBarBehavior.floating), + style: StreamBottomAppBarStyle(surfaceStyle: .floating), ), child: StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), ), ), ); - // The nav bar resolves only from its own theme and StreamAppStyle, so a + // The nav bar resolves only from its own theme and StreamSurfaceStyle, so a // floating StreamBottomAppBarTheme has no effect (defaults to regular). expect(_isFloating(tester), isFalse); }); - testWidgets('falls back to the ambient StreamAppStyle when neither instance nor theme set a behavior', ( + testWidgets('falls back to the ambient StreamSurfaceStyle when neither instance nor theme set a surfaceStyle', ( tester, ) async { await tester.pumpWidget( _withStreamTheme( StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), - appStyle: StreamAppStyle.floating, + surfaceStyle: StreamSurfaceStyle.floating, ), ); @@ -241,4 +311,71 @@ void main() { handle.dispose(); }); }); + + group('floating pill margin', () { + // The pill is the only Material carrying a RoundedRectangleBorder shape. + final pillFinder = find.byWidgetPredicate( + (widget) => widget is Material && widget.shape is RoundedRectangleBorder, + ); + + Future pumpFloating(WidgetTester tester, {required double deviceBottom}) { + return tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: StreamSurfaceStyle.floating)]), + home: Builder( + builder: (context) { + final base = MediaQuery.of(context); + return MediaQuery( + data: base.copyWith( + padding: EdgeInsets.only(bottom: deviceBottom), + viewPadding: EdgeInsets.only(bottom: deviceBottom), + ), + child: Align( + alignment: Alignment.bottomCenter, + child: StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (_) {}, + style: const StreamBottomNavBarStyle(surfaceStyle: .floating), + ), + ), + ); + }, + ), + ), + ); + } + + double gapBelowPill(WidgetTester tester) { + final barBottom = tester.getRect(find.byType(StreamBottomNavBar)).bottom; + final pillBottom = tester.getRect(pillFinder).bottom; + return barBottom - pillBottom; + } + + // Representative bottom system insets (viewPadding.bottom) per navigation + // mode. The pill floors its bottom gap at StreamSpacing.safeAreaBottom + // (platform-adaptive: xxl on iOS/macOS, xxxl elsewhere): a larger system + // inset (iOS home indicator, Android nav bar) is used as-is so the pill sits + // flush above it, while a device that reserves nothing (non-edge-to-edge, + // inset 0) still gets the floor. Gap = max(inset, floor). + const navigationModes = { + 'no inset': 0, + 'Android gesture (floating) nav': 24, + 'iOS home indicator': 34, + 'Android 2- and 3-button nav': 48, + }; + + for (final MapEntry(key: mode, value: inset) in navigationModes.entries) { + testWidgets( + 'floors the bottom gap at safeAreaBottom — $mode ($inset)', + (tester) async { + final floor = const StreamSpacing().safeAreaBottom(); + await pumpFloating(tester, deviceBottom: inset); + + expect(gapBelowPill(tester), moreOrLessEquals(math.max(inset, floor), epsilon: 0.5)); + }, + variant: const TargetPlatformVariant({TargetPlatform.iOS, TargetPlatform.android}), + ); + } + }); } diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_toolbar_button_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_toolbar_button_test.dart new file mode 100644 index 00000000..bab2c088 --- /dev/null +++ b/packages/stream_core_flutter/test/components/toolbar/stream_toolbar_button_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +Widget _withStreamTheme(Widget child, {StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular}) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + home: Scaffold(body: child), + ); +} + +Widget _scoped(StreamSurfaceStyle surfaceStyle, Widget child) { + return StreamToolbarScope(surfaceStyle: surfaceStyle, child: child); +} + +void main() { + group('StreamToolbarScope', () { + testWidgets('maybeOf returns null with no scope in the tree', (tester) async { + StreamSurfaceStyle? captured = StreamSurfaceStyle.floating; + await tester.pumpWidget( + _withStreamTheme( + Builder( + builder: (context) { + captured = StreamToolbarScope.maybeOf(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(captured, isNull); + }); + + testWidgets('of throws a FlutterError with no scope in the tree', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Builder( + builder: (context) { + StreamToolbarScope.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(tester.takeException(), isA()); + }); + + testWidgets('of and maybeOf return the published surfaceStyle inside a scope', (tester) async { + late StreamSurfaceStyle fromOf; + StreamSurfaceStyle? fromMaybeOf; + await tester.pumpWidget( + _withStreamTheme( + _scoped( + StreamSurfaceStyle.floating, + Builder( + builder: (context) { + fromOf = StreamToolbarScope.of(context); + fromMaybeOf = StreamToolbarScope.maybeOf(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(fromOf, StreamSurfaceStyle.floating); + expect(fromMaybeOf, StreamSurfaceStyle.floating); + }); + }); + + group('StreamToolbarButton', () { + testWidgets('renders an outlined, floating button inside a floating toolbar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _scoped( + StreamSurfaceStyle.floating, + StreamToolbarButton(onPressed: () {}, child: const Text('Edit')), + ), + ), + ); + + final button = tester.widget(find.byType(StreamButton)); + expect(button.props.type, StreamButtonType.outline); + expect(button.props.isFloating, isTrue); + }); + + testWidgets('renders an outlined, docked button inside a regular toolbar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _scoped( + StreamSurfaceStyle.regular, + StreamToolbarButton(onPressed: () {}, child: const Text('Edit')), + ), + ), + ); + + final button = tester.widget(find.byType(StreamButton)); + expect(button.props.type, StreamButtonType.outline); + expect(button.props.isFloating, isFalse); + }); + + testWidgets('renders a ghost, docked icon button inside a regular toolbar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _scoped( + StreamSurfaceStyle.regular, + StreamToolbarButton.icon(onPressed: () {}, icon: const Icon(Icons.close)), + ), + ), + ); + + final button = tester.widget(find.byType(StreamButton)); + expect(button.props.type, StreamButtonType.ghost); + expect(button.props.isFloating, isFalse); + }); + + testWidgets('an explicit type overrides the resolved shape', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _scoped( + StreamSurfaceStyle.floating, + StreamToolbarButton(type: StreamButtonType.solid, onPressed: () {}, child: const Text('Done')), + ), + ), + ); + + final button = tester.widget(find.byType(StreamButton)); + expect(button.props.type, StreamButtonType.solid); + // Elevation still follows the bar. + expect(button.props.isFloating, isTrue); + }); + }); +} diff --git a/packages/stream_core_flutter/test/theme/stream_theme_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_test.dart index d9412ea4..c4f8b557 100644 --- a/packages/stream_core_flutter/test/theme/stream_theme_test.dart +++ b/packages/stream_core_flutter/test/theme/stream_theme_test.dart @@ -5,11 +5,11 @@ import 'package:stream_core_flutter/core.dart'; void main() { group('StreamTheme.applyPlatform', () { test('carries non-typography fields through unchanged', () { - final theme = StreamTheme(platform: TargetPlatform.android, appStyle: StreamAppStyle.floating); + final theme = StreamTheme(platform: TargetPlatform.android, surfaceStyle: StreamSurfaceStyle.floating); final iosTheme = theme.applyPlatform(TargetPlatform.iOS); - expect(iosTheme.appStyle, equals(StreamAppStyle.floating)); + expect(iosTheme.surfaceStyle, equals(StreamSurfaceStyle.floating)); // ignore: deprecated_member_use_from_same_package expect(iosTheme.brightness, equals(theme.brightness)); expect(iosTheme.colorScheme.brightness, equals(theme.colorScheme.brightness));