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 b89aa862..8c61b245 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 @@ -120,6 +120,8 @@ import 'package:design_system_gallery/components/toolbar/stream_app_bar.dart' as _design_system_gallery_components_toolbar_stream_app_bar; import 'package:design_system_gallery/components/toolbar/stream_bottom_app_bar.dart' as _design_system_gallery_components_toolbar_stream_bottom_app_bar; +import 'package:design_system_gallery/components/toolbar/stream_bottom_nav_bar.dart' + 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/primitives/colors.dart' @@ -1232,6 +1234,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamBottomNavBar', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_toolbar_stream_bottom_nav_bar + .buildStreamBottomNavBarPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_toolbar_stream_bottom_nav_bar + .buildStreamBottomNavBarShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamSheetHeader', useCases: [ diff --git a/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart index 841c9030..520081ac 100644 --- a/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart +++ b/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart @@ -35,6 +35,11 @@ Widget buildStreamAvatarPlayground(BuildContext context) { description: 'Whether to show a border around the avatar.', ); + final isFloating = context.knobs.boolean( + label: 'Is Floating', + description: 'Whether to show a drop shadow around the avatar.', + ); + final initials = context.knobs.string( label: 'Initials', initialValue: 'JD', @@ -46,6 +51,7 @@ Widget buildStreamAvatarPlayground(BuildContext context) { imageUrl: (imageUrl?.isNotEmpty ?? false) ? imageUrl : null, size: size, showBorder: showBorder, + isFloating: isFloating, placeholder: (context) => Text( initials.substring(0, initials.length.clamp(0, 2)).toUpperCase(), ), diff --git a/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart index 33b2251c..de58637f 100644 --- a/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart +++ b/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart @@ -43,11 +43,17 @@ Widget buildStreamAvatarGroupPlayground(BuildContext context) { description: 'Use images or show initials placeholder.', ); + final isFloating = context.knobs.boolean( + label: 'Is Floating', + description: 'Whether to show a drop shadow around each individual avatar.', + ); + final palette = context.streamColorScheme.avatarPalette; return Center( child: StreamAvatarGroup( size: size, + isFloating: isFloating, children: List.generate( avatarCount, (index) => StreamAvatar( diff --git a/apps/design_system_gallery/lib/components/avatar/stream_avatar_stack.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar_stack.dart index 1f767097..ad07bc3f 100644 --- a/apps/design_system_gallery/lib/components/avatar/stream_avatar_stack.dart +++ b/apps/design_system_gallery/lib/components/avatar/stream_avatar_stack.dart @@ -58,6 +58,11 @@ Widget buildStreamAvatarStackPlayground(BuildContext context) { description: 'Use images or show initials placeholder.', ); + final isFloating = context.knobs.boolean( + label: 'Is Floating', + description: 'Whether to show a drop shadow around each individual avatar.', + ); + final colorScheme = context.streamColorScheme; final palette = colorScheme.avatarPalette; @@ -66,6 +71,7 @@ Widget buildStreamAvatarStackPlayground(BuildContext context) { size: size, overlap: overlap, max: maxAvatars, + isFloating: isFloating, children: [ for (var i = 0; i < avatarCount; i++) StreamAvatar( 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 2b5957ca..6d8907ac 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 @@ -53,31 +53,62 @@ Widget buildStreamAppBarPlayground(BuildContext context) { description: 'Horizontal gap between leading, heading, and trailing.', ); - return Align( - alignment: Alignment.topCenter, - child: StreamAppBar( - style: StreamAppBarStyle( - padding: EdgeInsets.all(padding), - spacing: spacing, - ), - leading: showLeading - ? StreamButton.icon( - icon: Icon(context.streamIcons.chevronLeft), - 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.plus), - onPressed: () {}, - ) - : null, + final floating = context.knobs.boolean( + label: 'Floating', + description: + 'When true, the app bar floats above content with a gradient fade ' + 'instead of a solid background and a bottom border.', + ); + + final colorScheme = context.streamColorScheme; + + final Widget bar = StreamAppBar( + primary: false, + style: StreamAppBarStyle( + behavior: floating ? StreamAppBarBehavior.floating : StreamAppBarBehavior.regular, + padding: EdgeInsets.all(padding), + spacing: spacing, ), + leading: showLeading + ? StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + 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.plus), + isFloating: floating, + onPressed: () {}, + ) + : null, ); + + if (floating) { + return Stack( + children: [ + Positioned.fill( + child: ColoredBox( + color: colorScheme.backgroundApp, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + ), + ), + ), + ), + Align(alignment: Alignment.topCenter, child: bar), + ], + ); + } + + return Align(alignment: Alignment.topCenter, child: bar); } // ============================================================================= @@ -227,6 +258,31 @@ Widget buildStreamAppBarShowcase(BuildContext context) { ), ), SizedBox(height: spacing.md), + _AppBarExample( + label: 'Floating — gradient fade over content', + bar: _FloatingAppBarPreview( + bar: StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + leading: StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + isFloating: true, + onPressed: () {}, + ), + title: const Text('Group chat'), + subtitle: const Text('5 members, 3 online'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + isFloating: true, + onPressed: () {}, + ), + ), + ), + ), + SizedBox(height: spacing.md), const _AutoImplyLeadingDemo(), ], ), @@ -303,6 +359,41 @@ class _AutoImplyLeadingDemo extends StatelessWidget { } } +/// Renders a floating [StreamAppBar] over a simulated gradient background so +/// the fade effect is clearly visible in the Showcase. +class _FloatingAppBarPreview extends StatelessWidget { + const _FloatingAppBarPreview({required this.bar}); + + final Widget bar; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return SizedBox( + height: kStreamToolbarHeight * 2, + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.accentPrimary.withAlpha(0x40), + colorScheme.backgroundApp, + ], + ), + ), + ), + ), + Positioned(top: 0, left: 0, right: 0, child: bar), + ], + ), + ); + } +} + class _AppBarExample extends StatelessWidget { const _AppBarExample({required this.label, required this.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 new file mode 100644 index 00000000..5ff177f9 --- /dev/null +++ b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_nav_bar.dart @@ -0,0 +1,231 @@ +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; + +// The tabs used across the playground and showcase. Icons switch to their +// filled variant when the item is selected. +List _navItems(BuildContext context) { + final icons = context.streamIcons; + return [ + 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', + ), + ]; +} + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamBottomNavBar, + path: '[Components]/Toolbar', +) +Widget buildStreamBottomNavBarPlayground(BuildContext context) { + return const _PlaygroundDemo(); +} + +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); + + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} + +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + var _currentIndex = 0; + + @override + Widget build(BuildContext context) { + final floating = context.knobs.boolean( + label: 'Floating', + description: + 'When true, the bar renders as a rounded pill with a gradient fade ' + 'instead of a docked bar with a top border.', + ); + + final itemCount = context.knobs.int.slider( + label: 'Item count', + initialValue: 4, + min: 2, + max: 4, + description: 'Number of navigation tabs to display (2–4).', + ); + + final items = _navItems(context).take(itemCount).toList(); + // Keep the selected index in range as the item count changes. + final currentIndex = _currentIndex.clamp(0, items.length - 1); + + return Align( + alignment: Alignment.bottomCenter, + child: StreamBottomNavBar( + items: items, + currentIndex: currentIndex, + onTap: (index) => setState(() => _currentIndex = index), + behavior: floating ? StreamBottomNavBarBehavior.floating : StreamBottomNavBarBehavior.regular, + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamBottomNavBar, + path: '[Components]/Toolbar', +) +Widget buildStreamBottomNavBarShowcase(BuildContext context) { + return const _ShowcaseDemo(); +} + +class _ShowcaseDemo extends StatefulWidget { + const _ShowcaseDemo(); + + @override + State<_ShowcaseDemo> createState() => _ShowcaseDemoState(); +} + +class _ShowcaseDemoState extends State<_ShowcaseDemo> { + var _regularIndex = 0; + var _floatingIndex = 1; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + final items = _navItems(context); + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BarExample( + label: 'Regular — docked with a top border', + bar: StreamBottomNavBar( + items: items, + currentIndex: _regularIndex, + onTap: (index) => setState(() => _regularIndex = index), + behavior: StreamBottomNavBarBehavior.regular, + ), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Floating — pill with a gradient fade over content', + bar: _FloatingNavBarPreview( + bar: StreamBottomNavBar( + items: items, + currentIndex: _floatingIndex, + onTap: (index) => setState(() => _floatingIndex = index), + behavior: StreamBottomNavBarBehavior.floating, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Renders a floating [StreamBottomNavBar] over a simulated content gradient so +/// the fade effect is clearly visible in the Showcase. +class _FloatingNavBarPreview extends StatelessWidget { + const _FloatingNavBarPreview({required this.bar}); + + final Widget bar; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return SizedBox( + 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), + ], + ), + ); + } +} + +class _BarExample extends StatelessWidget { + const _BarExample({required this.label, required this.bar}); + + final String label; + final Widget bar; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.xs), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + ), + // The clip follows the outer rounded rect, so at the corners the bar + // paints over an inside-aligned border. Draw it in the foreground. + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: bar, + ), + ], + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/scoped_semantics_debugger.dart b/apps/design_system_gallery/lib/widgets/scoped_semantics_debugger.dart index 4e86ab52..0b97528a 100644 --- a/apps/design_system_gallery/lib/widgets/scoped_semantics_debugger.dart +++ b/apps/design_system_gallery/lib/widgets/scoped_semantics_debugger.dart @@ -48,21 +48,29 @@ class _ScopedSemanticsDebuggerState extends State with @override void didUpdateWidget(ScopedSemanticsDebugger oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.enabled != oldWidget.enabled) { - widget.enabled ? _attach() : _detach(); + if (widget.enabled == oldWidget.enabled) return; + if (widget.enabled) { + _attach(); + // didChangeDependencies isn't re-run on a plain prop toggle, so wire the + // owner here — otherwise build sees a null owner right after enabling. + _syncOwner(); + } else { + _detach(); } } @override void didChangeDependencies() { super.didChangeDependencies(); - if (!widget.enabled) return; + if (widget.enabled) _syncOwner(); + } + + void _syncOwner() { final newOwner = View.pipelineOwnerOf(context); - if (newOwner != _pipelineOwner) { - _pipelineOwner?.semanticsOwner?.removeListener(_update); - newOwner.semanticsOwner?.addListener(_update); - _pipelineOwner = newOwner; - } + if (newOwner == _pipelineOwner) return; + _pipelineOwner?.semanticsOwner?.removeListener(_update); + newOwner.semanticsOwner?.addListener(_update); + _pipelineOwner = newOwner; } @override @@ -100,11 +108,12 @@ class _ScopedSemanticsDebuggerState extends State with @override Widget build(BuildContext context) { - if (!widget.enabled) return widget.child; + final owner = _pipelineOwner; + if (!widget.enabled || owner == null) return widget.child; return CustomPaint( key: _paintKey, foregroundPainter: _ScopedSemanticsDebuggerPainter( - owner: _pipelineOwner!, + owner: owner, generation: _generation, devicePixelRatio: View.of(context).devicePixelRatio, dimColor: context.streamColorScheme.backgroundApp, diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index da016bb0..1d1cf953 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -23,6 +23,12 @@ ### ✨ Features - Split the public API into `package:stream_core_flutter/core.dart` (shared primitives for any Stream SDK) and `package:stream_core_flutter/chat.dart` (chat-only widgets; re-exports `core.dart`); the convenience barrel `package:stream_core_flutter/stream_core_flutter.dart` is now deprecated. +- 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 `showShadow` to `StreamAvatar`, `StreamAvatarGroup`, and `StreamAvatarStack`. When true, applies the `StreamAvatarThemeData.boxShadow` (falling back to `StreamBoxShadow.elevation3`) as a drop shadow around the avatar shape. Fixed: a themed `boxShadow` in `StreamAvatarThemeData` no longer bleeds onto avatars where `showShadow` is `false`. +- 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. +- Floating buttons (`isFloating: true`) now retain their pill surface (`backgroundElevation1`) when disabled, across all style/type combinations. Previously, outline and ghost variants fell back to transparent when disabled, losing the floating visual. - Added `StreamSnackbar` and `StreamSnackbarTheme` — Stream-styled transient feedback snackbars with a messenger-driven queue. - Added `StreamIcons.megaphone` and `StreamIcons.shield` (20px) to the icon set. - Added `StreamMentionType` identifier for supported mention types and options for mention text customisation per type. diff --git a/packages/stream_core_flutter/lib/core.dart b/packages/stream_core_flutter/lib/core.dart index c469241a..d9e5e9b1 100644 --- a/packages/stream_core_flutter/lib/core.dart +++ b/packages/stream_core_flutter/lib/core.dart @@ -52,10 +52,12 @@ export 'src/components/emoji/data/stream_supported_emojis.dart'; export 'src/components/emoji/stream_emoji_picker_sheet.dart'; export 'src/components/list/stream_list_tile.dart'; export 'src/components/media_viewer/stream_media_viewer.dart'; +export 'src/components/scaffold/stream_scaffold.dart'; export 'src/components/sheet/stream_sheet.dart'; export 'src/components/snackbar/stream_snackbar.dart'; export 'src/components/toolbar/stream_app_bar.dart'; 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/factory/stream_component_factory.dart'; @@ -66,6 +68,7 @@ export 'src/theme/components/stream_avatar_theme.dart'; export 'src/theme/components/stream_badge_count_theme.dart'; export 'src/theme/components/stream_badge_notification_theme.dart'; export 'src/theme/components/stream_bottom_app_bar_theme.dart'; +export 'src/theme/components/stream_bottom_nav_bar_theme.dart'; export 'src/theme/components/stream_button_theme.dart'; export 'src/theme/components/stream_checkbox_theme.dart'; export 'src/theme/components/stream_context_menu_action_theme.dart'; @@ -95,6 +98,8 @@ 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_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 cf323b5c..547f2077 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 @@ -85,6 +85,7 @@ class StreamAvatar extends StatelessWidget { Color? backgroundColor, Color? foregroundColor, bool showBorder = true, + bool? isFloating, String? semanticsLabel, }) : props = .new( size: size, @@ -93,6 +94,7 @@ class StreamAvatar extends StatelessWidget { backgroundColor: backgroundColor, foregroundColor: foregroundColor, showBorder: showBorder, + isFloating: isFloating, semanticsLabel: semanticsLabel, ); @@ -125,6 +127,7 @@ class StreamAvatarProps { this.backgroundColor, this.foregroundColor, this.showBorder = true, + this.isFloating, this.semanticsLabel, }); @@ -168,6 +171,12 @@ class StreamAvatarProps { /// [StreamAvatarThemeData.border]. final bool showBorder; + /// 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. + final bool? isFloating; + /// Screen-reader label for the avatar. /// /// When null (the default), the placeholder speaks for itself — wrap in @@ -202,42 +211,53 @@ class DefaultStreamAvatar extends StatelessWidget { final effectiveSize = props.size ?? avatarTheme.size ?? defaults.size; final effectiveBackgroundColor = props.backgroundColor ?? avatarTheme.backgroundColor ?? defaults.backgroundColor; final effectiveForegroundColor = props.foregroundColor ?? avatarTheme.foregroundColor ?? defaults.foregroundColor; + final effectiveIsFloating = props.isFloating ?? avatarTheme.isFloating ?? false; + final effectiveElevation = effectiveIsFloating + ? (avatarTheme.floatingElevation ?? defaults.floatingElevation) + : 0.0; final effectiveBorder = avatarTheme.border ?? defaults.border; - final border = props.showBorder ? effectiveBorder : null; + // Avatars are circular, so the border is always uniform — use any side. + final borderSide = props.showBorder ? effectiveBorder.top : BorderSide.none; final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveForegroundColor); final iconTheme = IconTheme.of(context).copyWith( color: effectiveForegroundColor, size: _iconSizeForSize(effectiveSize), ); - Widget avatar = AnimatedContainer( - alignment: .center, - clipBehavior: .antiAlias, - width: effectiveSize.value, - height: effectiveSize.value, - duration: kThemeChangeDuration, - foregroundDecoration: BoxDecoration(shape: .circle, border: border), - decoration: BoxDecoration(shape: .circle, color: effectiveBackgroundColor), - child: Center( - // Need to disable text scaling here so that the text doesn't - // escape the avatar when the textScaleFactor is large. - child: MediaQuery.withNoTextScaling( - child: IconTheme( - data: iconTheme, - child: DefaultTextStyle( - style: textStyle, - child: switch (props.imageUrl) { - final imageUrl? => StreamNetworkImage( - imageUrl, - fit: .cover, - width: effectiveSize.value, - height: effectiveSize.value, - placeholderBuilder: (context) => Center(child: props.placeholder.call(context)), - errorBuilder: (context, _, _) => Center(child: props.placeholder.call(context)), + // Material clips children via PhysicalShape, so a border with strokeAlignOutside + // on the Material's shape gets clipped. Draw the border outside Material instead. + Widget avatar = SizedBox.square( + dimension: effectiveSize.value, + child: DecoratedBox( + decoration: ShapeDecoration(shape: CircleBorder(side: borderSide)), + position: DecorationPosition.foreground, + child: Material( + shape: const CircleBorder(), + color: effectiveBackgroundColor, + elevation: effectiveElevation, + clipBehavior: .antiAlias, + child: Center( + // Need to disable text scaling here so that the text doesn't + // escape the avatar when the textScaleFactor is large. + child: MediaQuery.withNoTextScaling( + child: IconTheme( + data: iconTheme, + child: DefaultTextStyle( + style: textStyle, + child: switch (props.imageUrl) { + final imageUrl? => StreamNetworkImage( + imageUrl, + fit: .cover, + width: effectiveSize.value, + height: effectiveSize.value, + placeholderBuilder: (context) => Center(child: props.placeholder.call(context)), + errorBuilder: (context, _, _) => Center(child: props.placeholder.call(context)), + ), + _ => props.placeholder.call(context), + }, ), - _ => props.placeholder.call(context), - }, + ), ), ), ), @@ -296,6 +316,9 @@ class _StreamAvatarThemeDefaults extends StreamAvatarThemeData { final BuildContext context; final StreamColorScheme _colorScheme; + @override + double get floatingElevation => 3; + @override StreamAvatarSize get size => StreamAvatarSize.lg; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart index f46afef1..e8c48df4 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart @@ -91,8 +91,14 @@ class StreamAvatarGroup extends StatelessWidget { super.key, StreamAvatarGroupSize? size, required Iterable children, + bool isFloating = false, String? semanticsLabel, - }) : props = .new(size: size, children: children, semanticsLabel: semanticsLabel); + }) : props = .new( + size: size, + children: children, + isFloating: isFloating, + semanticsLabel: semanticsLabel, + ); /// The properties that configure this avatar group. final StreamAvatarGroupProps props; @@ -119,6 +125,7 @@ class StreamAvatarGroupProps { const StreamAvatarGroupProps({ this.size, required this.children, + this.isFloating = false, this.semanticsLabel, }); @@ -132,6 +139,12 @@ class StreamAvatarGroupProps { /// If null, defaults to [StreamAvatarGroupSize.lg]. final StreamAvatarGroupSize? size; + /// Whether each individual avatar is in a floating state, rendering with a drop shadow. + /// + /// Defaults to false. The elevation used is [StreamAvatarThemeData.floatingElevation], + /// falling back to `3`. + final bool isFloating; + /// Screen-reader label for the avatar group. /// /// When null (the default), the group composes through — each child's own @@ -184,6 +197,7 @@ class DefaultStreamAvatarGroup extends StatelessWidget { color: colorScheme.borderOnInverse, strokeAlign: BorderSide.strokeAlignOutside, ), + isFloating: props.isFloating, ), child: StreamBadgeCountTheme( data: StreamBadgeCountThemeData(size: badgeCountSize), diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart index 560b5d31..654685f4 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -102,6 +102,7 @@ class StreamAvatarStack extends StatelessWidget { required Iterable children, double overlap = 0.33, int max = 5, + bool isFloating = false, String? semanticsLabel, }) : assert(max >= 2, 'max must be at least 2'), props = .new( @@ -109,6 +110,7 @@ class StreamAvatarStack extends StatelessWidget { children: children, overlap: overlap, max: max, + isFloating: isFloating, semanticsLabel: semanticsLabel, ); @@ -139,6 +141,7 @@ class StreamAvatarStackProps { required this.children, this.overlap = 0.33, this.max = 5, + this.isFloating = false, this.semanticsLabel, }); @@ -167,6 +170,12 @@ class StreamAvatarStackProps { /// Must be at least 2. Defaults to 5. final int max; + /// Whether each individual avatar is in a floating state, rendering with a drop shadow. + /// + /// Defaults to false. The elevation used is [StreamAvatarThemeData.floatingElevation], + /// falling back to `3`. + final bool isFloating; + /// Screen-reader label for the avatar stack. /// /// When null (the default), the stack composes through — each child's own @@ -218,6 +227,7 @@ class DefaultStreamAvatarStack extends StatelessWidget { color: colorScheme.borderOnInverse, strokeAlign: BorderSide.strokeAlignOutside, ), + isFloating: props.isFloating, ), child: StreamRow( spacing: -diameter * props.overlap, diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index e454bb52..b16f6410 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -496,7 +496,7 @@ mixin _SharedButtonDefaults on StreamButtonThemeStyle { @override WidgetStateProperty get elevation => WidgetStateProperty.resolveWith((states) { if (!isFloating) return 0; - if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.disabled)) return 6.0; if (states.contains(WidgetState.pressed)) return 6.0; if (states.contains(WidgetState.hovered)) return 8.0; return 6.0; @@ -560,8 +560,8 @@ class _PrimaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonD @override WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return StreamColors.transparent; final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); return base; }); @@ -600,8 +600,8 @@ class _PrimaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDef @override WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return StreamColors.transparent; final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); return base; }); @@ -670,8 +670,8 @@ class _SecondaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButto @override WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return StreamColors.transparent; final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); return base; }); @@ -710,8 +710,8 @@ class _SecondaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonD @override WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return StreamColors.transparent; final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); return base; }); @@ -780,8 +780,8 @@ class _DestructiveOutlineDefaults extends StreamButtonThemeStyle with _SharedBut @override WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return StreamColors.transparent; final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); return base; }); @@ -820,8 +820,8 @@ class _DestructiveGhostDefaults extends StreamButtonThemeStyle with _SharedButto @override WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return StreamColors.transparent; final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); return base; }); 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 new file mode 100644 index 00000000..61eac1c9 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/scaffold/stream_scaffold.dart @@ -0,0 +1,365 @@ +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_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. +/// +/// [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. +/// +/// ## Floating vs. regular +/// +/// The behaviour of each slot is resolved via a three-step priority chain: +/// +/// 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]). +/// +/// * [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. +/// * `regular` for either slot — no overlap; the slot occupies its own space +/// and the corresponding inset is `0.0`. +/// +/// ## Drawer support +/// +/// [drawer] is forwarded to the underlying [Scaffold] so that widgets in +/// [appBar] (e.g. `StreamChannelListHeader`) can find the drawer via +/// `Scaffold.maybeOf(context)?.openDrawer()`. +/// +/// ## InheritedWidget access +/// +/// ```dart +/// // Inside body: +/// final insets = StreamScaffoldInsets.of(context); +/// StreamMessageListView( +/// topPadding: insets.topPadding, +/// bottomPadding: insets.bottomPadding, +/// ); +/// ``` +/// +/// 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 { + /// Creates a Stream scaffold. + const StreamScaffold({ + super.key, + this.appBar, + required this.body, + this.bottom, + this.drawer, + this.endDrawer, + this.appBarBehavior, + this.bottomBarBehavior, + this.backgroundColor, + this.resizeToAvoidBottomInset = true, + }); + + /// An optional app bar displayed at the top of the scaffold. + /// + /// Must implement [PreferredSizeWidget] so the scaffold can read the height + /// for inset calculations. + 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. + 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]). + 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]. + final Widget? endDrawer; + + /// Per-instance override for the app-bar floating behaviour. + /// + /// When null the value is resolved from [StreamAppBarStyle.behavior] + /// in the ambient [StreamAppBarTheme], falling back to the ambient + /// [StreamAppStyle]. + final StreamAppBarBehavior? appBarBehavior; + + /// Per-instance override for the bottom-bar floating behaviour. + /// + /// When null the value is resolved from + /// [StreamBottomAppBarStyle.behavior] in the ambient + /// [StreamBottomAppBarTheme], falling back to the ambient [StreamAppStyle]. + final StreamBottomAppBarBehavior? bottomBarBehavior; + + /// Background color of the scaffold. + /// + /// Defaults to [StreamColorScheme.backgroundApp]. + final Color? backgroundColor; + + /// Whether the scaffold should resize to avoid the on-screen keyboard. + /// + /// Defaults to `true`. + final bool resizeToAvoidBottomInset; + + @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, + ], + ), + ); + } + + 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. + appBar: appBar, + drawer: drawer, + endDrawer: endDrawer, + 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, + child: body, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Custom layout +// --------------------------------------------------------------------------- + +/// Layout slot identifiers used by [_StreamScaffoldBodyDelegate]. +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. +class _BodyBoxConstraints extends BoxConstraints { + const _BodyBoxConstraints({ + super.maxWidth, + super.maxHeight, + required this.bottomHeight, + }) : assert(bottomHeight >= 0, 'bottomHeight must be non-negative'); + + /// The measured height of the floating bottom slot. + final double bottomHeight; + + @override + bool operator ==(Object other) { + if (super != other) return false; + return other is _BodyBoxConstraints && other.bottomHeight == bottomHeight; + } + + @override + 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]. +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)); + } + + layoutChild( + _Slot.body, + _BodyBoxConstraints( + maxWidth: size.width, + maxHeight: size.height, + bottomHeight: bottomHeight, + ), + ); + positionChild(_Slot.body, Offset.zero); + } + + @override + bool shouldRelayout(_StreamScaffoldBodyDelegate oldDelegate) => hasBottom != oldDelegate.hasBottom; +} + +/// 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. +class _StreamScaffoldBody extends StatelessWidget { + const _StreamScaffoldBody({ + required this.topInset, + required this.bottom, + required this.child, + }); + + final double topInset; + final Widget? bottom; + final Widget child; + + @override + Widget build(BuildContext context) { + final hasBottom = bottom != null; + + return CustomMultiChildLayout( + delegate: _StreamScaffoldBodyDelegate(hasBottom: hasBottom), + 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, + child: child, + ); + }, + ), + ), + if (hasBottom) 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 fbe4dd74..4a275114 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 @@ -7,6 +7,7 @@ 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 '../buttons/stream_button.dart'; import 'stream_toolbar.dart'; @@ -214,10 +215,16 @@ class DefaultStreamAppBar extends StatelessWidget { final icons = context.streamIcons; final spacing = context.streamSpacing; + final appStyle = context.streamTheme.appStyle; + final style = context.streamAppBarTheme.style?.merge(props.style) ?? props.style; final defaults = _StreamAppBarStyleDefaults(context); + var effectiveBehavior = style?.behavior ?? defaults.behavior; + effectiveBehavior ??= appStyle.isFloating ? .floating : .regular; + 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; @@ -242,7 +249,11 @@ class DefaultStreamAppBar extends StatelessWidget { final useCloseIcon = parentRoute is PageRoute && parentRoute.fullscreenDialog; final localizations = MaterialLocalizations.of(context); leading = StreamButton.icon( - type: .ghost, + type: switch (effectiveBehavior) { + .floating => .outline, + .regular => .ghost, + }, + isFloating: effectiveBehavior == .floating, style: .secondary, tooltip: useCloseIcon ? localizations.closeButtonTooltip : localizations.backButtonTooltip, icon: Icon(useCloseIcon ? icons.xmark : backIcon), @@ -348,15 +359,37 @@ class DefaultStreamAppBar extends StatelessWidget { container: true, child: DecoratedBox( decoration: BoxDecoration( - color: effectiveBackgroundColor, - border: Border( - bottom: BorderSide(color: context.streamColorScheme.borderSubtle), - ), + color: switch (effectiveBehavior) { + .floating => null, + .regular => effectiveBackgroundColor, + }, + gradient: switch (effectiveBehavior) { + .floating => _getFloatingGradient(context, color: effectiveFloatingBackgroundColor), + .regular => null, + }, + border: switch (effectiveBehavior) { + .floating => null, + .regular => Border(bottom: BorderSide(color: context.streamColorScheme.borderSubtle)), + }, ), child: Semantics(explicitChildNodes: true, 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 status bar and fades only + // in the toolbar zone below it. + final safeAreaTop = props.primary ? MediaQuery.paddingOf(context).top : 0.0; + final totalHeight = safeAreaTop + kStreamToolbarHeight; + final solidFraction = totalHeight > 0 ? safeAreaTop / totalHeight : 0.0; + + return streamFloatingFadeLinearGradient(color: color, solidFraction: solidFraction); + } } // Default style values for [StreamAppBar]. @@ -376,6 +409,9 @@ class _StreamAppBarStyleDefaults extends StreamAppBarStyle { @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 new file mode 100644 index 00000000..57d4b274 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_nav_bar.dart @@ -0,0 +1,563 @@ +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/stream_floating_fade.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// 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]. +/// +/// 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({ + required this.icon, + required this.selectedIcon, + required this.label, + }); + + /// The icon displayed when this item is inactive. + final Widget icon; + + /// The icon displayed when this item is active. + final Widget selectedIcon; + + /// The text label shown below the icon. + final String label; +} + +/// 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]. +/// +/// ## Floating style +/// +/// When [StreamBottomNavBarBehavior.floating] is in effect, the bar renders as a +/// horizontally padded pill with a rounded background, a subtle box 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 +/// `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. +/// +/// ## Theming +/// +/// Item colours, 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. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamBottomNavBar( +/// currentIndex: _currentIndex, +/// onTap: (i) => setState(() => _currentIndex = i), +/// items: const [ +/// StreamBottomNavBarItem( +/// icon: Icon(Icons.chat_bubble_outline), +/// selectedIcon: Icon(Icons.chat_bubble), +/// label: 'Chats', +/// ), +/// StreamBottomNavBarItem( +/// icon: Icon(Icons.bookmark_outline), +/// selectedIcon: Icon(Icons.bookmark), +/// label: 'Saved', +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomNavBarItem], which configures each tab. +/// * [StreamBottomNavBarTheme], which styles this widget. +/// * [StreamScaffold], which accepts this widget in its `bottom` slot. +/// * [DefaultStreamBottomNavBar], the default visual implementation. +class StreamBottomNavBar extends StatelessWidget { + /// Creates a Stream bottom navigation bar. + StreamBottomNavBar({ + super.key, + required List items, + required int currentIndex, + required ValueChanged onTap, + StreamBottomNavBarBehavior? behavior, + StreamBottomNavBarStyle? style, + }) : assert(items.length >= 2, 'StreamBottomNavBar requires at least 2 items'), + assert( + currentIndex >= 0 && currentIndex < items.length, + 'currentIndex must be within the items range: ' + '0 <= $currentIndex < ${items.length}', + ), + props = .new( + items: items, + currentIndex: currentIndex, + onTap: onTap, + behavior: behavior, + style: style, + ); + + /// The properties that configure this navigation bar. + final StreamBottomNavBarProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).bottomNavBar; + if (builder != null) return builder(context, props); + return DefaultStreamBottomNavBar(props: props); + } +} + +/// Properties for configuring a [StreamBottomNavBar]. +/// +/// This class holds all configuration options for a navigation bar, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamBottomNavBar], which uses these properties. +/// * [DefaultStreamBottomNavBar], the default implementation. +class StreamBottomNavBarProps { + /// Creates properties for a bottom navigation bar. + const StreamBottomNavBarProps({ + required this.items, + required this.currentIndex, + required this.onTap, + this.behavior, + this.style, + }) : assert( + currentIndex >= 0 && currentIndex < items.length, + 'currentIndex must be within the items range', + ); + + /// The list of items to display in the navigation bar. + /// + /// Must contain at least 2 items. + final List items; + + /// The index of the currently selected item. + /// + /// Must be a valid index into [items]. + final int currentIndex; + + /// 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 + /// [StreamBottomNavBarTheme] → token-backed defaults. + final StreamBottomNavBarStyle? style; +} + +/// The default implementation of [StreamBottomNavBar]. +/// +/// 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 +/// 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 +/// states on tap. +/// +/// See also: +/// +/// * [StreamBottomNavBar], the public API widget. +/// * [StreamBottomNavBarProps], which configures this widget. +class DefaultStreamBottomNavBar extends StatefulWidget { + /// Creates a default bottom navigation bar with the given [props]. + const DefaultStreamBottomNavBar({super.key, required this.props}); + + /// The properties that configure this navigation bar. + final StreamBottomNavBarProps props; + + @override + State createState() => _DefaultStreamBottomNavBarState(); +} + +class _DefaultStreamBottomNavBarState extends State with TickerProviderStateMixin { + var _controllers = []; + var _animations = []; + + List get _items => widget.props.items; + + void _resetState() { + for (final controller in _controllers) { + controller.dispose(); + } + for (final animation in _animations) { + animation.dispose(); + } + + _controllers = List.generate(_items.length, (index) { + return AnimationController(duration: kThemeAnimationDuration, vsync: this)..addListener(_rebuild); + }); + _animations = List.generate(_items.length, (index) { + return CurvedAnimation( + parent: _controllers[index], + curve: Curves.fastOutSlowIn, + reverseCurve: Curves.fastOutSlowIn.flipped, + ); + }); + _controllers[widget.props.currentIndex].value = 1; + } + + @override + void initState() { + super.initState(); + _resetState(); + } + + void _rebuild() { + setState(() { + // Rebuild when any controller ticks so the tiles animate. + }); + } + + @override + void dispose() { + for (final controller in _controllers) { + controller.dispose(); + } + for (final animation in _animations) { + animation.dispose(); + } + super.dispose(); + } + + @override + void didUpdateWidget(DefaultStreamBottomNavBar oldWidget) { + super.didUpdateWidget(oldWidget); + + // No animated segue if the number of items changes. + if (_items.length != oldWidget.props.items.length) { + _resetState(); + return; + } + + if (widget.props.currentIndex != oldWidget.props.currentIndex) { + _controllers[oldWidget.props.currentIndex].reverse(); + _controllers[widget.props.currentIndex].forward(); + } + } + + List _createTiles({ + required double iconSize, + required Color selectedItemColor, + required Color unselectedItemColor, + required TextStyle selectedLabelStyle, + required TextStyle unselectedLabelStyle, + }) { + final localizations = MaterialLocalizations.of(context); + + // Selected and unselected tiles share the animated colour 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( + item: _items[i], + animation: _animations[i], + iconSize: iconSize, + selected: i == widget.props.currentIndex, + onTap: () => widget.props.onTap(i), + colorTween: colorTween, + labelStyle: i == widget.props.currentIndex ? selectedLabelStyle : unselectedLabelStyle, + indexLabel: localizations.tabLabel(tabIndex: i + 1, tabCount: _items.length), + ), + ]; + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasDirectionality(context), 'A Directionality ancestor is required.'); + 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 effectiveBackgroundColor = style?.backgroundColor ?? defaults.backgroundColor; + final effectiveFloatingBackgroundColor = style?.floatingBackgroundColor ?? defaults.floatingBackgroundColor; + final effectiveSelectedItemColor = style?.selectedItemColor ?? defaults.selectedItemColor; + final effectiveUnselectedItemColor = style?.unselectedItemColor ?? defaults.unselectedItemColor; + final effectiveIconSize = style?.iconSize ?? defaults.iconSize; + final effectiveSelectedLabelStyle = style?.selectedLabelStyle ?? defaults.selectedLabelStyle; + final effectiveUnselectedLabelStyle = style?.unselectedLabelStyle ?? defaults.unselectedLabelStyle; + final effectiveBorderColor = style?.borderColor ?? defaults.borderColor; + final effectiveBorderRadius = style?.borderRadius ?? defaults.borderRadius; + + final tiles = StreamBadgeNotificationTheme( + data: const StreamBadgeNotificationThemeData(size: StreamBadgeNotificationSize.xs), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: kBottomNavigationBarHeight), + // A transparent surface above the bar background so each tile can paint + // its tap ripple. + child: Material( + type: MaterialType.transparency, + child: DefaultTextStyle.merge( + overflow: TextOverflow.ellipsis, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: _createTiles( + iconSize: effectiveIconSize, + selectedItemColor: effectiveSelectedItemColor, + unselectedItemColor: effectiveUnselectedItemColor, + selectedLabelStyle: effectiveSelectedLabelStyle, + unselectedLabelStyle: effectiveUnselectedLabelStyle, + ), + ), + ), + ), + ), + ); + + return Semantics( + explicitChildNodes: true, + child: switch (effectiveBehavior) { + StreamBottomNavBarBehavior.regular => _RegularChrome( + backgroundColor: effectiveBackgroundColor, + borderColor: effectiveBorderColor, + child: tiles, + ), + StreamBottomNavBarBehavior.floating => _FloatingChrome( + pillColor: effectiveBackgroundColor, + gradientColor: effectiveFloatingBackgroundColor, + borderColor: effectiveBorderColor, + borderRadius: effectiveBorderRadius, + child: tiles, + ), + }, + ); + } +} + +// A single navigation tile: an icon above a label, both sharing a colour that +// animates between the unselected and selected states. +class _StreamNavTile extends StatelessWidget { + const _StreamNavTile({ + required this.item, + required this.animation, + required this.iconSize, + required this.selected, + required this.onTap, + required this.colorTween, + required this.labelStyle, + required this.indexLabel, + }); + + final StreamBottomNavBarItem item; + final Animation animation; + final double iconSize; + final bool selected; + final VoidCallback onTap; + final ColorTween colorTween; + final TextStyle labelStyle; + final String indexLabel; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final color = colorTween.evaluate(animation); + + final Widget result = Semantics( + selected: selected, + button: true, + container: true, + child: Stack( + children: [ + InkResponse( + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric(vertical: spacing.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxxs, + children: [ + Align( + alignment: Alignment.topCenter, + heightFactor: 1, + child: IconTheme( + data: IconThemeData(color: color, size: iconSize), + child: selected ? item.selectedIcon : item.icon, + ), + ), + Align( + alignment: Alignment.bottomCenter, + heightFactor: 1, + child: MediaQuery.withClampedTextScaling( + maxScaleFactor: 1, + child: Text(item.label, style: labelStyle.copyWith(color: color)), + ), + ), + ], + ), + ), + ), + Semantics(label: indexLabel), + ], + ), + ); + + return Expanded(child: result); + } +} + +// Docked chrome: a solid surface with a hairline top border, inset above the +// system bottom inset. +class _RegularChrome extends StatelessWidget { + const _RegularChrome({ + required this.backgroundColor, + required this.borderColor, + required this.child, + }); + + final Color backgroundColor; + final Color borderColor; + final Widget child; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + border: Border(top: BorderSide(color: borderColor)), + ), + child: SafeArea(top: false, child: child), + ); + } +} + +// Floating chrome: a rounded pill with a shadow and hairline border, sitting +// above a gradient that fades the bar into the content behind it. +class _FloatingChrome extends StatelessWidget { + const _FloatingChrome({ + required this.pillColor, + required this.gradientColor, + required this.borderColor, + required this.borderRadius, + required this.child, + }); + + final Color pillColor; + final Color gradientColor; + final Color borderColor; + final BorderRadiusGeometry borderRadius; + final Widget child; + + LinearGradient _buildGradient(BuildContext context) { + final safeAreaBottom = MediaQuery.paddingOf(context).bottom; + // Approximate rendered height: safe area + item height. + const itemHeight = kBottomNavigationBarHeight; + final totalHeight = safeAreaBottom + itemHeight; + final solidFraction = totalHeight > 0 ? safeAreaBottom / totalHeight : 0.0; + + return streamFloatingFadeLinearGradient( + color: gradientColor, + solidFraction: solidFraction, + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return DecoratedBox( + decoration: BoxDecoration(gradient: _buildGradient(context)), + child: SafeArea( + 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, + ), + ), + ), + ); + } +} + +// Default style values for [StreamBottomNavBar]. +// +// These defaults are used when no explicit value is provided via +// [StreamBottomNavBarStyle]. They are context-aware and use values from +// [StreamColorScheme], [StreamTextTheme], and [StreamRadius]. +class _StreamBottomNavBarStyleDefaults extends StreamBottomNavBarStyle { + _StreamBottomNavBarStyleDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + late final _radius = _context.streamRadius; + + @override + Color get backgroundColor => _colorScheme.backgroundElevation1; + + @override + Color get floatingBackgroundColor => _colorScheme.backgroundElevation0; + + @override + Color get selectedItemColor => _colorScheme.textPrimary; + + @override + Color get unselectedItemColor => _colorScheme.textTertiary; + + @override + double get iconSize => 20; + + @override + TextStyle get selectedLabelStyle => _textTheme.metadataEmphasis; + + @override + TextStyle get unselectedLabelStyle => _textTheme.metadataEmphasis; + + @override + Color get borderColor => _colorScheme.borderSubtle; + + @override + BorderRadiusGeometry get borderRadius => BorderRadius.all(_radius.max); +} diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index 7e10b59c..b332a687 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -48,6 +48,7 @@ import '../components/reaction/stream_reactions.dart'; import '../components/snackbar/stream_snackbar.dart'; import '../components/toolbar/stream_app_bar.dart'; import '../components/toolbar/stream_bottom_app_bar.dart'; +import '../components/toolbar/stream_bottom_nav_bar.dart'; import '../components/toolbar/stream_sheet_header.dart'; part 'stream_component_factory.g.theme.dart'; @@ -181,6 +182,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? appBar, StreamComponentBuilder? avatar, StreamComponentBuilder? bottomAppBar, + StreamComponentBuilder? bottomNavBar, StreamComponentBuilder? avatarGroup, StreamComponentBuilder? avatarStack, StreamComponentBuilder? badgeCount, @@ -234,6 +236,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { appBar: appBar, avatar: avatar, bottomAppBar: bottomAppBar, + bottomNavBar: bottomNavBar, avatarGroup: avatarGroup, avatarStack: avatarStack, badgeCount: badgeCount, @@ -288,6 +291,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.appBar, required this.avatar, required this.bottomAppBar, + required this.bottomNavBar, required this.avatarGroup, required this.avatarStack, required this.badgeCount, @@ -370,6 +374,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamBottomAppBar] uses [DefaultStreamBottomAppBar]. final StreamComponentBuilder? bottomAppBar; + /// Custom builder for bottom navigation bar widgets. + /// + /// When null, [StreamBottomNavBar] uses [DefaultStreamBottomNavBar]. + final StreamComponentBuilder? bottomNavBar; + /// Custom builder for avatar group widgets. /// /// When null, [StreamAvatarGroup] uses [DefaultStreamAvatarGroup]. diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart index 7c0bfb6f..1f39ed62 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart @@ -34,6 +34,7 @@ mixin _$StreamComponentBuilders { appBar: t < 0.5 ? a.appBar : b.appBar, avatar: t < 0.5 ? a.avatar : b.avatar, bottomAppBar: t < 0.5 ? a.bottomAppBar : b.bottomAppBar, + bottomNavBar: t < 0.5 ? a.bottomNavBar : b.bottomNavBar, avatarGroup: t < 0.5 ? a.avatarGroup : b.avatarGroup, avatarStack: t < 0.5 ? a.avatarStack : b.avatarStack, badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, @@ -103,6 +104,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamAppBarProps)? appBar, Widget Function(BuildContext, StreamAvatarProps)? avatar, Widget Function(BuildContext, StreamBottomAppBarProps)? bottomAppBar, + Widget Function(BuildContext, StreamBottomNavBarProps)? bottomNavBar, Widget Function(BuildContext, StreamAvatarGroupProps)? avatarGroup, Widget Function(BuildContext, StreamAvatarStackProps)? avatarStack, Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, @@ -179,6 +181,7 @@ mixin _$StreamComponentBuilders { appBar: appBar ?? _this.appBar, avatar: avatar ?? _this.avatar, bottomAppBar: bottomAppBar ?? _this.bottomAppBar, + bottomNavBar: bottomNavBar ?? _this.bottomNavBar, avatarGroup: avatarGroup ?? _this.avatarGroup, avatarStack: avatarStack ?? _this.avatarStack, badgeCount: badgeCount ?? _this.badgeCount, @@ -255,6 +258,7 @@ mixin _$StreamComponentBuilders { appBar: other.appBar, avatar: other.avatar, bottomAppBar: other.bottomAppBar, + bottomNavBar: other.bottomNavBar, avatarGroup: other.avatarGroup, avatarStack: other.avatarStack, badgeCount: other.badgeCount, @@ -323,6 +327,7 @@ mixin _$StreamComponentBuilders { _other.appBar == _this.appBar && _other.avatar == _this.avatar && _other.bottomAppBar == _this.bottomAppBar && + _other.bottomNavBar == _this.bottomNavBar && _other.avatarGroup == _this.avatarGroup && _other.avatarStack == _this.avatarStack && _other.badgeCount == _this.badgeCount && @@ -386,6 +391,7 @@ mixin _$StreamComponentBuilders { _this.appBar, _this.avatar, _this.bottomAppBar, + _this.bottomNavBar, _this.avatarGroup, _this.avatarStack, _this.badgeCount, 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 31341006..6e504cee 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 @@ -6,6 +6,24 @@ 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. @@ -136,7 +154,9 @@ class StreamAppBarThemeData with _$StreamAppBarThemeData { class StreamAppBarStyle with _$StreamAppBarStyle { /// Creates an app bar style with optional property overrides. const StreamAppBarStyle({ + this.behavior, this.backgroundColor, + this.floatingBackgroundColor, this.padding, this.spacing, this.titleTextStyle, @@ -145,9 +165,22 @@ class StreamAppBarStyle with _$StreamAppBarStyle { this.trailingStyle, }); + /// The floating or regular layout behaviour 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; + /// The background colour of the app bar. final Color? backgroundColor; + /// The background colour of the floating app bar. + final Color? floatingBackgroundColor; + /// The padding around the header's content row. final EdgeInsetsGeometry? padding; 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 5f0e722e..afac78d2 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,13 @@ mixin _$StreamAppBarStyle { } return StreamAppBarStyle( + behavior: t < 0.5 ? a.behavior : b.behavior, 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), @@ -122,7 +128,9 @@ mixin _$StreamAppBarStyle { } StreamAppBarStyle copyWith({ + StreamAppBarBehavior? behavior, Color? backgroundColor, + Color? floatingBackgroundColor, EdgeInsetsGeometry? padding, double? spacing, TextStyle? titleTextStyle, @@ -133,7 +141,10 @@ mixin _$StreamAppBarStyle { final _this = (this as StreamAppBarStyle); return StreamAppBarStyle( + behavior: behavior ?? _this.behavior, backgroundColor: backgroundColor ?? _this.backgroundColor, + floatingBackgroundColor: + floatingBackgroundColor ?? _this.floatingBackgroundColor, padding: padding ?? _this.padding, spacing: spacing ?? _this.spacing, titleTextStyle: titleTextStyle ?? _this.titleTextStyle, @@ -155,7 +166,9 @@ mixin _$StreamAppBarStyle { } return copyWith( + behavior: other.behavior, backgroundColor: other.backgroundColor, + floatingBackgroundColor: other.floatingBackgroundColor, padding: other.padding, spacing: other.spacing, titleTextStyle: @@ -185,7 +198,9 @@ mixin _$StreamAppBarStyle { final _this = (this as StreamAppBarStyle); final _other = (other as StreamAppBarStyle); - return _other.backgroundColor == _this.backgroundColor && + return _other.behavior == _this.behavior && + _other.backgroundColor == _this.backgroundColor && + _other.floatingBackgroundColor == _this.floatingBackgroundColor && _other.padding == _this.padding && _other.spacing == _this.spacing && _other.titleTextStyle == _this.titleTextStyle && @@ -200,7 +215,9 @@ mixin _$StreamAppBarStyle { return Object.hash( runtimeType, + _this.behavior, _this.backgroundColor, + _this.floatingBackgroundColor, _this.padding, _this.spacing, _this.titleTextStyle, 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 34da5832..ea8e3f9b 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 @@ -132,6 +132,8 @@ class StreamAvatarThemeData with _$StreamAvatarThemeData { this.backgroundColor, this.foregroundColor, this.border, + this.isFloating, + this.floatingElevation, }); /// The default size for avatars. @@ -156,6 +158,19 @@ class StreamAvatarThemeData with _$StreamAvatarThemeData { /// of both border color and width. final BoxBorder? border; + /// Whether the avatar is in a floating state, rendering with elevation. + /// + /// When set via [StreamAvatarTheme], this overrides the per-widget + /// [StreamAvatar.isFloating] default, allowing parent widgets such as + /// [StreamAvatarGroup] to enable floating for all child avatars at once. + final bool? isFloating; + + /// 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`. + final double? floatingElevation; + /// Linearly interpolate between two [StreamAvatarThemeData] objects. static StreamAvatarThemeData? lerp( StreamAvatarThemeData? a, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart index 4a2b8836..1c1f7e4a 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart @@ -34,6 +34,12 @@ mixin _$StreamAvatarThemeData { backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t), border: BoxBorder.lerp(a.border, b.border, t), + isFloating: t < 0.5 ? a.isFloating : b.isFloating, + floatingElevation: lerpDouble$( + a.floatingElevation, + b.floatingElevation, + t, + ), ); } @@ -42,6 +48,8 @@ mixin _$StreamAvatarThemeData { Color? backgroundColor, Color? foregroundColor, BoxBorder? border, + bool? isFloating, + double? floatingElevation, }) { final _this = (this as StreamAvatarThemeData); @@ -50,6 +58,8 @@ mixin _$StreamAvatarThemeData { backgroundColor: backgroundColor ?? _this.backgroundColor, foregroundColor: foregroundColor ?? _this.foregroundColor, border: border ?? _this.border, + isFloating: isFloating ?? _this.isFloating, + floatingElevation: floatingElevation ?? _this.floatingElevation, ); } @@ -69,6 +79,8 @@ mixin _$StreamAvatarThemeData { backgroundColor: other.backgroundColor, foregroundColor: other.foregroundColor, border: other.border, + isFloating: other.isFloating, + floatingElevation: other.floatingElevation, ); } @@ -88,7 +100,9 @@ mixin _$StreamAvatarThemeData { return _other.size == _this.size && _other.backgroundColor == _this.backgroundColor && _other.foregroundColor == _this.foregroundColor && - _other.border == _this.border; + _other.border == _this.border && + _other.isFloating == _this.isFloating && + _other.floatingElevation == _this.floatingElevation; } @override @@ -101,6 +115,8 @@ mixin _$StreamAvatarThemeData { _this.backgroundColor, _this.foregroundColor, _this.border, + _this.isFloating, + _this.floatingElevation, ); } } 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 ce125fe6..c123c369 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 @@ -6,6 +6,25 @@ 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 @@ -139,6 +158,7 @@ class StreamBottomAppBarThemeData with _$StreamBottomAppBarThemeData { class StreamBottomAppBarStyle with _$StreamBottomAppBarStyle { /// Creates a bottom app bar style with optional property overrides. const StreamBottomAppBarStyle({ + this.behavior, this.backgroundColor, this.padding, this.spacing, @@ -148,6 +168,16 @@ 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]. + /// + /// Set this to override the global style for this component only, without + /// affecting other components. + final StreamBottomAppBarBehavior? behavior; + /// The background colour of the bottom app bar. final Color? backgroundColor; 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 cd9f90ba..508ff261 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,6 +99,7 @@ mixin _$StreamBottomAppBarStyle { } return StreamBottomAppBarStyle( + behavior: t < 0.5 ? a.behavior : b.behavior, backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), spacing: lerpDouble$(a.spacing, b.spacing, t), @@ -122,6 +123,7 @@ mixin _$StreamBottomAppBarStyle { } StreamBottomAppBarStyle copyWith({ + StreamBottomAppBarBehavior? behavior, Color? backgroundColor, EdgeInsetsGeometry? padding, double? spacing, @@ -133,6 +135,7 @@ mixin _$StreamBottomAppBarStyle { final _this = (this as StreamBottomAppBarStyle); return StreamBottomAppBarStyle( + behavior: behavior ?? _this.behavior, backgroundColor: backgroundColor ?? _this.backgroundColor, padding: padding ?? _this.padding, spacing: spacing ?? _this.spacing, @@ -155,6 +158,7 @@ mixin _$StreamBottomAppBarStyle { } return copyWith( + behavior: other.behavior, backgroundColor: other.backgroundColor, padding: other.padding, spacing: other.spacing, @@ -185,7 +189,8 @@ mixin _$StreamBottomAppBarStyle { final _this = (this as StreamBottomAppBarStyle); final _other = (other as StreamBottomAppBarStyle); - return _other.backgroundColor == _this.backgroundColor && + return _other.behavior == _this.behavior && + _other.backgroundColor == _this.backgroundColor && _other.padding == _this.padding && _other.spacing == _this.spacing && _other.titleTextStyle == _this.titleTextStyle && @@ -200,6 +205,7 @@ mixin _$StreamBottomAppBarStyle { return Object.hash( runtimeType, + _this.behavior, _this.backgroundColor, _this.padding, _this.spacing, 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 new file mode 100644 index 00000000..2d0f11a9 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.dart @@ -0,0 +1,207 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.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. +/// +/// Wrap a subtree with [StreamBottomNavBarTheme] to override navigation bar +/// styling. Access the merged theme using +/// [BuildContext.streamBottomNavBarTheme]. +/// +/// {@tool snippet} +/// +/// Override the selected item colour for a specific subtree: +/// +/// ```dart +/// StreamBottomNavBarTheme( +/// data: StreamBottomNavBarThemeData( +/// style: StreamBottomNavBarStyle(selectedItemColor: Color(0xFF005FFF)), +/// ), +/// child: StreamBottomNavBar(...), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomNavBarThemeData], which describes the navigation bar theme. +/// * [StreamBottomNavBarStyle], the reusable visual style embedded by the theme. +/// * [StreamBottomNavBar], the widget affected by this theme. +class StreamBottomNavBarTheme extends InheritedTheme { + /// Creates a bottom navigation bar theme that controls descendant bars. + const StreamBottomNavBarTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The bottom navigation bar theme data for descendant widgets. + final StreamBottomNavBarThemeData data; + + /// Returns the [StreamBottomNavBarThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamBottomNavBarTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamBottomNavBarThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).bottomNavBarTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamBottomNavBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamBottomNavBarTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamBottomNavBar] widgets. +/// +/// Wraps a [StreamBottomNavBarStyle] so it can be served by +/// [StreamBottomNavBarTheme] and slotted into [StreamTheme] alongside other +/// component theme data classes. +/// +/// {@tool snippet} +/// +/// Customize navigation bar appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// bottomNavBarTheme: StreamBottomNavBarThemeData( +/// style: StreamBottomNavBarStyle(iconSize: 24), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomNavBarStyle], the reusable visual style embedded here. +/// * [StreamBottomNavBarTheme], for overriding the theme in a widget subtree. +/// * [StreamBottomNavBar], the widget that uses this theme data. +@themeGen +@immutable +class StreamBottomNavBarThemeData with _$StreamBottomNavBarThemeData { + /// Creates bottom navigation bar theme data. + const StreamBottomNavBarThemeData({this.style}); + + /// Visual styling for the bottom navigation bar. + final StreamBottomNavBarStyle? style; + + /// Linearly interpolate between two [StreamBottomNavBarThemeData] objects. + static StreamBottomNavBarThemeData? lerp( + StreamBottomNavBarThemeData? a, + StreamBottomNavBarThemeData? b, + double t, + ) => _$StreamBottomNavBarThemeData.lerp(a, b, t); +} + +/// 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, +/// icon size, item label styles, and the border and corner radius used by the +/// floating pill. +/// +/// Exposed separately from [StreamBottomNavBarThemeData] so other theme data +/// classes can embed a navigation bar style via a typed field. +/// +/// {@tool snippet} +/// +/// Compose a style and hand it to a navigation bar theme: +/// +/// ```dart +/// StreamBottomNavBarStyle( +/// selectedItemColor: Color(0xFF080707), +/// unselectedItemColor: Color(0xFF7A7A7A), +/// iconSize: 20, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomNavBarThemeData], which wraps this style for theming. +/// * [StreamBottomNavBar], which uses this styling. +@themeGen +@immutable +class StreamBottomNavBarStyle with _$StreamBottomNavBarStyle { + /// Creates a bottom navigation bar style with optional property overrides. + const StreamBottomNavBarStyle({ + this.behavior, + this.backgroundColor, + this.floatingBackgroundColor, + this.selectedItemColor, + this.unselectedItemColor, + this.iconSize, + this.selectedLabelStyle, + this.unselectedLabelStyle, + this.borderColor, + this.borderRadius, + }); + + /// The floating or regular layout behaviour 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; + + /// The background colour of the docked bar and of the floating pill. + final Color? backgroundColor; + + /// The base colour of the floating gradient fade behind the pill. + final Color? floatingBackgroundColor; + + /// The colour of the icon and label of the selected item. + final Color? selectedItemColor; + + /// The colour of the icon and label of unselected items. + final Color? unselectedItemColor; + + /// The size of each item's icon. + final double? iconSize; + + /// The text style for the label of the selected item. + final TextStyle? selectedLabelStyle; + + /// 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. + final Color? borderColor; + + /// The corner radius of the floating pill. + final BorderRadiusGeometry? borderRadius; + + /// Linearly interpolate between two [StreamBottomNavBarStyle] objects. + static StreamBottomNavBarStyle? lerp( + StreamBottomNavBarStyle? a, + StreamBottomNavBarStyle? b, + double t, + ) => _$StreamBottomNavBarStyle.lerp(a, b, t); +} 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 new file mode 100644 index 00000000..15c863b8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_nav_bar_theme.g.theme.dart @@ -0,0 +1,240 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_bottom_nav_bar_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamBottomNavBarThemeData { + bool get canMerge => true; + + static StreamBottomNavBarThemeData? lerp( + StreamBottomNavBarThemeData? a, + StreamBottomNavBarThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamBottomNavBarThemeData( + style: StreamBottomNavBarStyle.lerp(a.style, b.style, t), + ); + } + + StreamBottomNavBarThemeData copyWith({StreamBottomNavBarStyle? style}) { + final _this = (this as StreamBottomNavBarThemeData); + + return StreamBottomNavBarThemeData(style: style ?? _this.style); + } + + StreamBottomNavBarThemeData merge(StreamBottomNavBarThemeData? other) { + final _this = (this as StreamBottomNavBarThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamBottomNavBarThemeData); + final _other = (other as StreamBottomNavBarThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamBottomNavBarThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamBottomNavBarStyle { + bool get canMerge => true; + + static StreamBottomNavBarStyle? lerp( + StreamBottomNavBarStyle? a, + StreamBottomNavBarStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamBottomNavBarStyle( + behavior: t < 0.5 ? a.behavior : b.behavior, + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + floatingBackgroundColor: Color.lerp( + a.floatingBackgroundColor, + b.floatingBackgroundColor, + t, + ), + selectedItemColor: Color.lerp( + a.selectedItemColor, + b.selectedItemColor, + t, + ), + unselectedItemColor: Color.lerp( + a.unselectedItemColor, + b.unselectedItemColor, + t, + ), + iconSize: lerpDouble$(a.iconSize, b.iconSize, t), + selectedLabelStyle: TextStyle.lerp( + a.selectedLabelStyle, + b.selectedLabelStyle, + t, + ), + unselectedLabelStyle: TextStyle.lerp( + a.unselectedLabelStyle, + b.unselectedLabelStyle, + t, + ), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + borderRadius: BorderRadiusGeometry.lerp( + a.borderRadius, + b.borderRadius, + t, + ), + ); + } + + StreamBottomNavBarStyle copyWith({ + StreamBottomNavBarBehavior? behavior, + Color? backgroundColor, + Color? floatingBackgroundColor, + Color? selectedItemColor, + Color? unselectedItemColor, + double? iconSize, + TextStyle? selectedLabelStyle, + TextStyle? unselectedLabelStyle, + Color? borderColor, + BorderRadiusGeometry? borderRadius, + }) { + final _this = (this as StreamBottomNavBarStyle); + + return StreamBottomNavBarStyle( + behavior: behavior ?? _this.behavior, + backgroundColor: backgroundColor ?? _this.backgroundColor, + floatingBackgroundColor: + floatingBackgroundColor ?? _this.floatingBackgroundColor, + selectedItemColor: selectedItemColor ?? _this.selectedItemColor, + unselectedItemColor: unselectedItemColor ?? _this.unselectedItemColor, + iconSize: iconSize ?? _this.iconSize, + selectedLabelStyle: selectedLabelStyle ?? _this.selectedLabelStyle, + unselectedLabelStyle: unselectedLabelStyle ?? _this.unselectedLabelStyle, + borderColor: borderColor ?? _this.borderColor, + borderRadius: borderRadius ?? _this.borderRadius, + ); + } + + StreamBottomNavBarStyle merge(StreamBottomNavBarStyle? other) { + final _this = (this as StreamBottomNavBarStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + behavior: other.behavior, + backgroundColor: other.backgroundColor, + floatingBackgroundColor: other.floatingBackgroundColor, + selectedItemColor: other.selectedItemColor, + unselectedItemColor: other.unselectedItemColor, + iconSize: other.iconSize, + selectedLabelStyle: + _this.selectedLabelStyle?.merge(other.selectedLabelStyle) ?? + other.selectedLabelStyle, + unselectedLabelStyle: + _this.unselectedLabelStyle?.merge(other.unselectedLabelStyle) ?? + other.unselectedLabelStyle, + borderColor: other.borderColor, + borderRadius: other.borderRadius, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamBottomNavBarStyle); + final _other = (other as StreamBottomNavBarStyle); + + return _other.behavior == _this.behavior && + _other.backgroundColor == _this.backgroundColor && + _other.floatingBackgroundColor == _this.floatingBackgroundColor && + _other.selectedItemColor == _this.selectedItemColor && + _other.unselectedItemColor == _this.unselectedItemColor && + _other.iconSize == _this.iconSize && + _other.selectedLabelStyle == _this.selectedLabelStyle && + _other.unselectedLabelStyle == _this.unselectedLabelStyle && + _other.borderColor == _this.borderColor && + _other.borderRadius == _this.borderRadius; + } + + @override + int get hashCode { + final _this = (this as StreamBottomNavBarStyle); + + return Object.hash( + runtimeType, + _this.behavior, + _this.backgroundColor, + _this.floatingBackgroundColor, + _this.selectedItemColor, + _this.unselectedItemColor, + _this.iconSize, + _this.selectedLabelStyle, + _this.unselectedLabelStyle, + _this.borderColor, + _this.borderRadius, + ); + } +} 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 new file mode 100644 index 00000000..29eb3aa5 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/stream_app_style.dart @@ -0,0 +1,57 @@ +/// 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_floating_fade.dart b/packages/stream_core_flutter/lib/src/theme/stream_floating_fade.dart new file mode 100644 index 00000000..07b4ff31 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/stream_floating_fade.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +/// Builds a [LinearGradient] that fades from a solid [color] into transparent, +/// suitable for floating app bars, bottom navigation bars, and composers. +/// +/// The gradient is solid from `0.0` up to [solidFraction] (the fraction of the +/// total height occupied by a system safe-area inset), then fades through four +/// alpha stops to fully transparent at `1.0`. +/// +/// [begin] and [end] control the direction of the fade: +/// * top-to-bottom (`Alignment.topCenter` → `Alignment.bottomCenter`) for +/// floating app bars, where the bar fades into the scrollable content below. +/// * bottom-to-top (`Alignment.bottomCenter` → `Alignment.topCenter`) for +/// floating nav bars and composers, where they fade into content above. +LinearGradient streamFloatingFadeLinearGradient({ + required Color color, + double solidFraction = 0.0, + AlignmentGeometry begin = Alignment.topCenter, + AlignmentGeometry end = Alignment.bottomCenter, +}) { + assert(solidFraction >= 0.0 && solidFraction <= 1.0, 'solidFraction must be in [0, 1]'); + final f = solidFraction; + + return LinearGradient( + colors: [ + color, + color, + color.withAlpha(0xE8), // ~91 % + color.withAlpha(0xA8), // ~66 % + color.withAlpha(0x40), // ~25 % + color.withAlpha(0x00), // transparent + ], + stops: [ + 0.0, + f, + f + (1 - f) * 0.55, + f + (1 - f) * 0.75, + f + (1 - f) * 0.90, + 1.0, + ], + begin: begin, + end: end, + ); +} 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 7102a56c..951c2f54 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -8,6 +8,7 @@ import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_badge_notification_theme.dart'; import 'components/stream_bottom_app_bar_theme.dart'; +import 'components/stream_bottom_nav_bar_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; import 'components/stream_command_chip_theme.dart'; @@ -45,6 +46,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'; part 'stream_theme.g.theme.dart'; @@ -104,6 +106,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { factory StreamTheme({ Brightness? brightness, TargetPlatform? platform, + StreamAppStyle? appStyle, StreamIcons? icons, StreamRadius? radius, StreamSpacing? spacing, @@ -118,6 +121,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamBadgeCountThemeData? badgeCountTheme, StreamBadgeNotificationThemeData? badgeNotificationTheme, StreamBottomAppBarThemeData? bottomAppBarTheme, + StreamBottomNavBarThemeData? bottomNavBarTheme, StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamCommandChipThemeData? commandChipTheme, @@ -170,12 +174,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { boxShadow ??= isDark ? StreamBoxShadow.dark() : StreamBoxShadow.light(); // Components + appStyle ??= StreamAppStyle.regular; appBarTheme ??= const StreamAppBarThemeData(); audioWaveformTheme ??= const StreamAudioWaveformThemeData(); avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); badgeNotificationTheme ??= const StreamBadgeNotificationThemeData(); bottomAppBarTheme ??= const StreamBottomAppBarThemeData(); + bottomNavBarTheme ??= const StreamBottomNavBarThemeData(); buttonTheme ??= const StreamButtonThemeData(); checkboxTheme ??= const StreamCheckboxThemeData(); commandChipTheme ??= const StreamCommandChipThemeData(); @@ -208,6 +214,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { switchTheme ??= const StreamSwitchThemeData(); return .raw( + appStyle: appStyle, icons: icons, radius: radius, spacing: spacing, @@ -221,6 +228,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { badgeCountTheme: badgeCountTheme, badgeNotificationTheme: badgeNotificationTheme, bottomAppBarTheme: bottomAppBarTheme, + bottomNavBarTheme: bottomNavBarTheme, buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, commandChipTheme: commandChipTheme, @@ -268,6 +276,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { const StreamTheme.raw({ @Deprecated('Use colorScheme.brightness instead') Brightness? brightness, + required this.appStyle, required this.icons, required this.radius, required this.spacing, @@ -281,6 +290,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.badgeCountTheme, required this.badgeNotificationTheme, required this.bottomAppBarTheme, + required this.bottomNavBarTheme, required this.buttonTheme, required this.checkboxTheme, required this.commandChipTheme, @@ -345,6 +355,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The brightness of this theme. Brightness get brightness => colorScheme.brightness; + /// The app style for this theme. + final StreamAppStyle appStyle; + /// The icons for this theme. final StreamIcons icons; @@ -389,6 +402,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The bottom app bar theme for this theme. final StreamBottomAppBarThemeData bottomAppBarTheme; + /// The bottom navigation bar theme for this theme. + final StreamBottomNavBarThemeData bottomNavBarTheme; + /// The button theme for this theme. final StreamButtonThemeData buttonTheme; @@ -501,6 +517,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { final newTextTheme = StreamTextTheme(typography: newTypography).apply(color: colorScheme.systemText); return StreamTheme.raw( + appStyle: appStyle, icons: icons, radius: radius, spacing: spacing, @@ -514,6 +531,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { badgeCountTheme: badgeCountTheme, badgeNotificationTheme: badgeNotificationTheme, bottomAppBarTheme: bottomAppBarTheme, + bottomNavBarTheme: bottomNavBarTheme, buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, commandChipTheme: commandChipTheme, 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 09e93405..0f341c3f 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,6 +13,7 @@ mixin _$StreamTheme on ThemeExtension { @override ThemeExtension copyWith({ Brightness? brightness, + StreamAppStyle? appStyle, StreamIcons? icons, StreamRadius? radius, StreamSpacing? spacing, @@ -26,6 +27,7 @@ mixin _$StreamTheme on ThemeExtension { StreamBadgeCountThemeData? badgeCountTheme, StreamBadgeNotificationThemeData? badgeNotificationTheme, StreamBottomAppBarThemeData? bottomAppBarTheme, + StreamBottomNavBarThemeData? bottomNavBarTheme, StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamCommandChipThemeData? commandChipTheme, @@ -67,6 +69,7 @@ mixin _$StreamTheme on ThemeExtension { return StreamTheme.raw( brightness: brightness ?? _this.brightness, + appStyle: appStyle ?? _this.appStyle, icons: icons ?? _this.icons, radius: radius ?? _this.radius, spacing: spacing ?? _this.spacing, @@ -81,6 +84,7 @@ mixin _$StreamTheme on ThemeExtension { badgeNotificationTheme: badgeNotificationTheme ?? _this.badgeNotificationTheme, bottomAppBarTheme: bottomAppBarTheme ?? _this.bottomAppBarTheme, + bottomNavBarTheme: bottomNavBarTheme ?? _this.bottomNavBarTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, checkboxTheme: checkboxTheme ?? _this.checkboxTheme, commandChipTheme: commandChipTheme ?? _this.commandChipTheme, @@ -144,6 +148,7 @@ mixin _$StreamTheme on ThemeExtension { return StreamTheme.raw( brightness: t < 0.5 ? _this.brightness : other.brightness, + appStyle: t < 0.5 ? _this.appStyle : other.appStyle, icons: StreamIcons.lerp(_this.icons, other.icons, t)!, radius: StreamRadius.lerp(_this.radius, other.radius, t)!, spacing: StreamSpacing.lerp(_this.spacing, other.spacing, t)!, @@ -182,6 +187,11 @@ mixin _$StreamTheme on ThemeExtension { other.bottomAppBarTheme, t, )!, + bottomNavBarTheme: StreamBottomNavBarThemeData.lerp( + _this.bottomNavBarTheme, + other.bottomNavBarTheme, + t, + )!, buttonTheme: StreamButtonThemeData.lerp( _this.buttonTheme, other.buttonTheme, @@ -356,6 +366,7 @@ mixin _$StreamTheme on ThemeExtension { final _other = (other as StreamTheme); return _other.brightness == _this.brightness && + _other.appStyle == _this.appStyle && _other.icons == _this.icons && _other.radius == _this.radius && _other.spacing == _this.spacing && @@ -369,6 +380,7 @@ mixin _$StreamTheme on ThemeExtension { _other.badgeCountTheme == _this.badgeCountTheme && _other.badgeNotificationTheme == _this.badgeNotificationTheme && _other.bottomAppBarTheme == _this.bottomAppBarTheme && + _other.bottomNavBarTheme == _this.bottomNavBarTheme && _other.buttonTheme == _this.buttonTheme && _other.checkboxTheme == _this.checkboxTheme && _other.commandChipTheme == _this.commandChipTheme && @@ -415,6 +427,7 @@ mixin _$StreamTheme on ThemeExtension { return Object.hashAll([ runtimeType, _this.brightness, + _this.appStyle, _this.icons, _this.radius, _this.spacing, @@ -428,6 +441,7 @@ mixin _$StreamTheme on ThemeExtension { _this.badgeCountTheme, _this.badgeNotificationTheme, _this.bottomAppBarTheme, + _this.bottomNavBarTheme, _this.buttonTheme, _this.checkboxTheme, _this.commandChipTheme, 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 67de4b23..b3025fd1 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 @@ -6,6 +6,7 @@ import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_badge_notification_theme.dart'; import 'components/stream_bottom_app_bar_theme.dart'; +import 'components/stream_bottom_nav_bar_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; import 'components/stream_command_chip_theme.dart'; @@ -108,6 +109,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamBottomAppBarThemeData] from the nearest ancestor. StreamBottomAppBarThemeData get streamBottomAppBarTheme => StreamBottomAppBarTheme.of(this); + /// Returns the [StreamBottomNavBarThemeData] from the nearest ancestor. + StreamBottomNavBarThemeData get streamBottomNavBarTheme => StreamBottomNavBarTheme.of(this); + /// Returns the [StreamButtonThemeData] from the nearest ancestor. StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); 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 new file mode 100644 index 00000000..4c8ab592 Binary files /dev/null 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 new file mode 100644 index 00000000..6130be6f Binary files /dev/null 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_dark.png b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_shadow_dark.png new file mode 100644 index 00000000..e4650e64 Binary files /dev/null and b/packages/stream_core_flutter/test/components/avatar/goldens/ci/stream_avatar_shadow_dark.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 new file mode 100644 index 00000000..fe5ca712 Binary files /dev/null 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 new file mode 100644 index 00000000..5ff0e365 Binary files /dev/null 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 new file mode 100644 index 00000000..058b89fd Binary files /dev/null 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/avatar/stream_avatar_golden_test.dart b/packages/stream_core_flutter/test/components/avatar/stream_avatar_golden_test.dart new file mode 100644 index 00000000..5c92c8b9 --- /dev/null +++ b/packages/stream_core_flutter/test/components/avatar/stream_avatar_golden_test.dart @@ -0,0 +1,231 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + group('StreamAvatar Golden Tests', () { + // ------------------------------------------------------------------------- + // StreamAvatar — shadow matrix (light) + // ------------------------------------------------------------------------- + goldenTest( + 'renders shadow variants in light theme', + fileName: 'stream_avatar_shadow_light', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 400), + children: [ + for (final size in StreamAvatarSize.values) ...[ + GoldenTestScenario( + name: '${size.name}_shadow_off', + child: _buildAvatarInTheme( + StreamAvatar( + size: size, + showBorder: false, + isFloating: false, + placeholder: (context) => const Text('AB'), + ), + ), + ), + GoldenTestScenario( + name: '${size.name}_shadow_on', + child: _buildAvatarInTheme( + StreamAvatar( + size: size, + showBorder: false, + isFloating: true, + placeholder: (context) => const Text('AB'), + ), + ), + ), + ], + ], + ), + ); + + // ------------------------------------------------------------------------- + // StreamAvatar — shadow matrix (dark) + // ------------------------------------------------------------------------- + goldenTest( + 'renders shadow variants in dark theme', + fileName: 'stream_avatar_shadow_dark', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 400), + children: [ + for (final size in StreamAvatarSize.values) ...[ + GoldenTestScenario( + name: '${size.name}_shadow_off', + child: _buildAvatarInTheme( + StreamAvatar( + size: size, + showBorder: false, + isFloating: false, + placeholder: (context) => const Text('AB'), + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: '${size.name}_shadow_on', + child: _buildAvatarInTheme( + StreamAvatar( + size: size, + showBorder: false, + isFloating: true, + placeholder: (context) => const Text('AB'), + ), + brightness: Brightness.dark, + ), + ), + ], + ], + ), + ); + + // ------------------------------------------------------------------------- + // StreamAvatarGroup — shadow on, various counts (light) + // ------------------------------------------------------------------------- + goldenTest( + 'renders avatar group with shadow in light theme', + fileName: 'stream_avatar_group_shadow_light', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 400), + children: [ + for (final size in StreamAvatarGroupSize.values) + for (final count in [2, 4, 5]) + GoldenTestScenario( + name: '${size.name}_count_$count', + child: _buildAvatarInTheme( + StreamAvatarGroup( + size: size, + isFloating: true, + children: List.generate( + count, + (i) => StreamAvatar( + placeholder: (context) => Text(_initials(i)), + ), + ), + ), + ), + ), + ], + ), + ); + + // ------------------------------------------------------------------------- + // StreamAvatarGroup — shadow on (dark) + // ------------------------------------------------------------------------- + goldenTest( + 'renders avatar group with shadow in dark theme', + fileName: 'stream_avatar_group_shadow_dark', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 400), + children: [ + for (final size in StreamAvatarGroupSize.values) + for (final count in [2, 4, 5]) + GoldenTestScenario( + name: '${size.name}_count_$count', + child: _buildAvatarInTheme( + StreamAvatarGroup( + size: size, + isFloating: true, + children: List.generate( + count, + (i) => StreamAvatar( + placeholder: (context) => Text(_initials(i)), + ), + ), + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + + // ------------------------------------------------------------------------- + // StreamAvatarStack — shadow on (light) + // ------------------------------------------------------------------------- + goldenTest( + 'renders avatar stack with shadow in light theme', + fileName: 'stream_avatar_stack_shadow_light', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 400), + children: [ + for (final size in StreamAvatarStackSize.values) + GoldenTestScenario( + name: size.name, + child: _buildAvatarInTheme( + StreamAvatarStack( + size: size, + isFloating: true, + children: List.generate( + 4, + (i) => StreamAvatar( + placeholder: (context) => Text(_initials(i)), + ), + ), + ), + ), + ), + ], + ), + ); + + // ------------------------------------------------------------------------- + // StreamAvatarStack — shadow on (dark) + // ------------------------------------------------------------------------- + goldenTest( + 'renders avatar stack with shadow in dark theme', + fileName: 'stream_avatar_stack_shadow_dark', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 400), + children: [ + for (final size in StreamAvatarStackSize.values) + GoldenTestScenario( + name: size.name, + child: _buildAvatarInTheme( + StreamAvatarStack( + size: size, + isFloating: true, + children: List.generate( + 4, + (i) => StreamAvatar( + placeholder: (context) => Text(_initials(i)), + ), + ), + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + }); +} + +Widget _buildAvatarInTheme( + Widget avatar, { + 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: Padding( + padding: const EdgeInsets.all(16), + child: avatar, + ), + ), + ), + ); +} + +String _initials(int index) { + const names = ['AB', 'CD', 'EF', 'GH', 'IJ']; + return names[index % names.length]; +} diff --git a/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart b/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart index ca76891d..644e7e66 100644 --- a/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart +++ b/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart @@ -10,6 +10,20 @@ Widget _withStreamTheme(Widget child) { } void main() { + testWidgets('StreamAvatar renders a StreamNetworkImage when imageUrl is set', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamAvatar( + imageUrl: 'https://example.com/avatar.png', + placeholder: (context) => const Text('A'), + ), + ), + ); + + final networkImage = tester.widget(find.byType(StreamNetworkImage)); + expect(networkImage.props.url, equals('https://example.com/avatar.png')); + }); + group('StreamAvatar a11y', () { testWidgets('semanticsLabel: null (default) — placeholder speaks for itself', (tester) async { final handle = tester.ensureSemantics(); diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only_floating.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only_floating.png new file mode 100644 index 00000000..04e82477 Binary files /dev/null and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only_floating.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only_floating_disabled.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only_floating_disabled.png new file mode 100644 index 00000000..bb670682 Binary files /dev/null and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only_floating_disabled.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/stream_button_golden_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_button_golden_test.dart index 54042a04..59bdc445 100644 --- a/packages/stream_core_flutter/test/components/buttons/stream_button_golden_test.dart +++ b/packages/stream_core_flutter/test/components/buttons/stream_button_golden_test.dart @@ -122,6 +122,54 @@ void main() { ), ); + goldenTest( + 'renders floating icon only button correctly', + fileName: 'stream_button_icon_only_floating', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + for (final style in StreamButtonStyle.values) + for (final type in StreamButtonType.values) + GoldenTestScenario( + name: '${style.name}_${type.name}', + child: _buildButtonInTheme( + StreamButton.icon( + onPressed: () {}, + style: style, + type: type, + isFloating: true, + icon: const Icon(Icons.add), + ), + ), + ), + ], + ), + ); + + goldenTest( + 'renders disabled floating icon only button correctly', + fileName: 'stream_button_icon_only_floating_disabled', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 300), + children: [ + for (final style in StreamButtonStyle.values) + for (final type in StreamButtonType.values) + GoldenTestScenario( + name: '${style.name}_${type.name}', + child: _buildButtonInTheme( + StreamButton.icon( + onPressed: null, + style: style, + type: type, + isFloating: true, + icon: const Icon(Icons.add), + ), + ), + ), + ], + ), + ); + goldenTest( 'renders disabled button correctly', fileName: 'stream_button_disabled', 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 new file mode 100644 index 00000000..b569089d --- /dev/null +++ b/packages/stream_core_flutter/test/components/scaffold/stream_scaffold_test.dart @@ -0,0 +1,218 @@ +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}) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + home: child, + ); +} + +class _InsetsProbe extends StatelessWidget { + const _InsetsProbe(); + + @override + Widget build(BuildContext context) { + final insets = StreamScaffoldInsets.of(context); + return Text('top:${insets.topPadding} bottom:${insets.bottomPadding}'); + } +} + +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()); + }); + + 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(); + }, + ), + ), + ), + ); + + expect(result, isNull); + }); + }); + + group('when neither slot is floating', () { + testWidgets('injects zero insets and forwards drawer/endDrawer', (tester) async { + const drawer = Drawer(key: ValueKey('drawer')); + const endDrawer = Drawer(key: ValueKey('end-drawer')); + + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold( + drawer: drawer, + endDrawer: endDrawer, + body: _InsetsProbe(), + ), + ), + ); + + 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)); + }); + + testWidgets('places a regular bottom widget below the body instead of in bottomNavigationBar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold( + body: SizedBox(), + bottom: Text('Bottom bar'), + ), + ), + ); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.bottomNavigationBar, isNull); + expect(find.text('Bottom bar'), findsOneWidget); + }); + }); + + 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; + + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold( + appBarBehavior: StreamAppBarBehavior.floating, + appBar: PreferredSize( + preferredSize: Size.fromHeight(appBarHeight), + child: SizedBox(), + ), + body: _InsetsProbe(), + ), + ), + ); + + 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; + + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold( + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + bottom: SizedBox(height: bottomHeight), + body: _InsetsProbe(), + ), + ), + ); + + 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(), + ), + ); + } + + await tester.pumpWidget(buildWithHeight(64)); + expect(find.text('top:0.0 bottom:64.0'), findsOneWidget); + + await tester.pumpWidget(buildWithHeight(80)); + expect(find.text('top:0.0 bottom:80.0'), findsOneWidget); + }); + + testWidgets('is not floating when no bottom widget is provided', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold( + bottomBarBehavior: StreamBottomAppBarBehavior.floating, + body: _InsetsProbe(), + ), + ), + ); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.extendBody, isFalse); + expect(find.text('top:0.0 bottom:0.0'), findsOneWidget); + }); + }); + + group('behavior resolution', () { + testWidgets('falls back to the component theme when no instance behavior is set', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + const StreamAppBarTheme( + data: StreamAppBarThemeData(style: StreamAppBarStyle(behavior: StreamAppBarBehavior.floating)), + child: StreamScaffold( + appBar: PreferredSize(preferredSize: Size.fromHeight(kToolbarHeight), child: SizedBox()), + body: SizedBox(), + ), + ), + ), + ); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.extendBodyBehindAppBar, isTrue); + }); + + testWidgets('falls back to the ambient StreamAppStyle when neither instance nor theme set a behavior', ( + tester, + ) async { + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold( + appBar: PreferredSize(preferredSize: Size.fromHeight(kToolbarHeight), child: SizedBox()), + body: SizedBox(), + ), + appStyle: StreamAppStyle.floating, + ), + ); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.extendBodyBehindAppBar, isTrue); + }); + }); + + testWidgets('applies the given background color', (tester) async { + const backgroundColor = Color(0xFF123456); + + await tester.pumpWidget( + _withStreamTheme( + const StreamScaffold(backgroundColor: backgroundColor, body: SizedBox()), + ), + ); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.backgroundColor, equals(backgroundColor)); + }); +} diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_dark.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_dark.png new file mode 100644 index 00000000..cb61c5a7 Binary files /dev/null and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_dark.png differ diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_floating_dark.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_floating_dark.png new file mode 100644 index 00000000..2cf37c52 Binary files /dev/null and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_floating_dark.png differ diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_floating_light.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_floating_light.png new file mode 100644 index 00000000..b057a311 Binary files /dev/null and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_floating_light.png differ diff --git a/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_light.png b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_light.png new file mode 100644 index 00000000..6390f7a8 Binary files /dev/null and b/packages/stream_core_flutter/test/components/toolbar/goldens/ci/stream_app_bar_light.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 new file mode 100644 index 00000000..cafa4f4c Binary files /dev/null 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 new file mode 100644 index 00000000..90387646 Binary files /dev/null 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 new file mode 100644 index 00000000..52dde5d0 --- /dev/null +++ b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_golden_test.dart @@ -0,0 +1,253 @@ +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('StreamAppBar Golden Tests', () { + goldenTest( + 'renders light theme variants', + fileName: 'stream_app_bar_light', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'title only', + child: _buildAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + title: const Text('Details'), + ), + ), + ), + GoldenTestScenario( + name: 'leading and trailing', + child: _buildAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + leading: StreamButton.icon( + icon: const Icon(Icons.chevron_left), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('Group chat'), + trailing: StreamButton.icon( + icon: const Icon(Icons.add), + onPressed: () {}, + ), + ), + ), + ), + GoldenTestScenario( + name: 'leading only', + child: _buildAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + leading: StreamButton.icon( + icon: const Icon(Icons.chevron_left), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('Details'), + ), + ), + ), + ], + ), + ); + + goldenTest( + 'renders dark theme variants', + fileName: 'stream_app_bar_dark', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'title only', + child: _buildAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + title: const Text('Details'), + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'leading and trailing', + child: _buildAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + leading: StreamButton.icon( + icon: const Icon(Icons.chevron_left), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('Group chat'), + trailing: StreamButton.icon( + icon: const Icon(Icons.add), + onPressed: () {}, + ), + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + + goldenTest( + 'renders light theme floating variants', + fileName: 'stream_app_bar_floating_light', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'title only', + child: _buildFloatingAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + title: const Text('Details'), + ), + ), + ), + GoldenTestScenario( + name: 'leading and trailing', + child: _buildFloatingAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + leading: StreamButton.icon( + icon: const Icon(Icons.chevron_left), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + isFloating: true, + onPressed: () {}, + ), + title: const Text('Group chat'), + trailing: StreamButton.icon( + icon: const Icon(Icons.add), + isFloating: true, + onPressed: () {}, + ), + ), + ), + ), + ], + ), + ); + + goldenTest( + 'renders dark theme floating variants', + fileName: 'stream_app_bar_floating_dark', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'title only', + child: _buildFloatingAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + title: const Text('Details'), + ), + brightness: Brightness.dark, + ), + ), + GoldenTestScenario( + name: 'leading and trailing', + child: _buildFloatingAppBarInTheme( + StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + leading: StreamButton.icon( + icon: const Icon(Icons.chevron_left), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + isFloating: true, + onPressed: () {}, + ), + title: const Text('Group chat'), + trailing: StreamButton.icon( + icon: const Icon(Icons.add), + isFloating: true, + onPressed: () {}, + ), + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + }); +} + +Widget _buildAppBarInTheme( + Widget appBar, { + 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: appBar), + ), + ), + ); +} + +/// Wraps a floating [StreamAppBar] over a content background so the gradient +/// fade is clearly visible in the snapshot. +Widget _buildFloatingAppBarInTheme( + Widget appBar, { + 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 * 2, + child: Stack( + children: [ + // Simulated content behind the floating bar. + Positioned.fill( + child: ColoredBox( + color: colorScheme.backgroundApp, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 32), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + ), + ), + ), + ), + Positioned(top: 0, left: 0, right: 0, child: appBar), + ], + ), + ); + }, + ), + ); +} 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 0be1e69d..e2bd5b51 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 @@ -82,6 +82,84 @@ void main() { }); }); + group('StreamAppBar floating', () { + testWidgets('floating: false uses solid background and bottom border', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + appBar: StreamAppBar( + automaticallyImplyLeading: false, + title: const Text('Title'), + ), + ), + ), + ); + + final decoratedBox = tester.widget( + find + .descendant( + of: find.byType(StreamAppBar), + 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: true uses gradient and no bottom border', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + body: StreamAppBar( + automaticallyImplyLeading: false, + primary: false, + style: const StreamAppBarStyle(behavior: StreamAppBarBehavior.floating), + title: const Text('Title'), + ), + ), + ), + ); + + final decoratedBox = tester.widget( + find + .descendant( + of: find.byType(StreamAppBar), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.color, isNull); + expect(decoration.gradient, isA()); + expect(decoration.border, isNull); + }); + + testWidgets('floating: true uses outline button type for auto-implied leading', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + const _LauncherScreen(appBarStyle: StreamAppBarStyle(behavior: StreamAppBarBehavior.floating)), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final button = tester.widget(find.byType(StreamButton)); + expect(button.props.type, equals(StreamButtonType.outline)); + }); + + testWidgets('floating: false uses ghost button type for auto-implied leading', (tester) async { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final button = tester.widget(find.byType(StreamButton)); + expect(button.props.type, equals(StreamButtonType.ghost)); + }); + }); + group('StreamAppBar semantics', () { testWidgets('auto-implied back button carries the localized Back tooltip', (tester) async { await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); @@ -289,11 +367,13 @@ class _LauncherScreen extends StatelessWidget { this.customLeading = false, this.implyLeading = true, this.fullscreenDialog = false, + this.appBarStyle, }); final bool customLeading; final bool implyLeading; final bool fullscreenDialog; + final StreamAppBarStyle? appBarStyle; @override Widget build(BuildContext context) { @@ -308,6 +388,7 @@ class _LauncherScreen extends StatelessWidget { builder: (_) => Scaffold( appBar: StreamAppBar( automaticallyImplyLeading: implyLeading, + style: appBarStyle, leading: customLeading ? const SizedBox(key: ValueKey('custom-leading'), width: 40, height: 40) : null, 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 new file mode 100644 index 00000000..6c831bae --- /dev/null +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_golden_test.dart @@ -0,0 +1,144 @@ +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; + +const _items = [ + StreamBottomNavBarItem( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chats', + ), + StreamBottomNavBarItem( + icon: Icon(Icons.alternate_email), + selectedIcon: Icon(Icons.alternate_email), + label: 'Mentions', + ), + StreamBottomNavBarItem( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profile', + ), +]; + +void main() { + group('StreamBottomNavBar Golden Tests', () { + goldenTest( + 'renders regular variants', + fileName: 'stream_bottom_nav_bar_regular', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'light', + child: _buildNavBarInTheme(_regularBar()), + ), + GoldenTestScenario( + name: 'dark', + child: _buildNavBarInTheme(_regularBar(), brightness: Brightness.dark), + ), + ], + ), + ); + + goldenTest( + 'renders floating variants', + fileName: 'stream_bottom_nav_bar_floating', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: _kBarWidth), + children: [ + GoldenTestScenario( + name: 'light', + child: _buildFloatingNavBarInTheme(_floatingBar()), + ), + GoldenTestScenario( + name: 'dark', + child: _buildFloatingNavBarInTheme(_floatingBar(), brightness: Brightness.dark), + ), + ], + ), + ); + }); +} + +Widget _regularBar() { + return StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (_) {}, + behavior: StreamBottomNavBarBehavior.regular, + ); +} + +Widget _floatingBar() { + return StreamBottomNavBar( + items: _items, + currentIndex: 1, + onTap: (_) {}, + behavior: StreamBottomNavBarBehavior.floating, + ); +} + +Widget _buildNavBarInTheme( + Widget navBar, { + 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: navBar), + ), + ), + ); +} + +/// Wraps a floating [StreamBottomNavBar] over a content gradient so the fade is +/// clearly visible in the snapshot. +Widget _buildFloatingNavBarInTheme( + Widget navBar, { + 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: navBar), + ], + ), + ); + }, + ), + ); +} 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 new file mode 100644 index 00000000..504e303b --- /dev/null +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_nav_bar_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +const _items = [ + StreamBottomNavBarItem( + icon: Icon(Icons.chat_bubble_outline), + selectedIcon: Icon(Icons.chat_bubble), + label: 'Chats', + ), + StreamBottomNavBarItem( + icon: Icon(Icons.bookmark_outline), + selectedIcon: Icon(Icons.bookmark), + label: 'Saved', + ), +]; + +Widget _withStreamTheme(Widget child, {StreamAppStyle appStyle = StreamAppStyle.regular}) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + home: Scaffold(body: child), + ); +} + +/// Whether the bar resolved to the floating style, detected by the presence of +/// the gradient fade (only the floating chrome paints one). +bool _isFloating(WidgetTester tester) { + final boxes = tester.widgetList( + find.descendant(of: find.byType(StreamBottomNavBar), matching: find.byType(DecoratedBox)), + ); + return boxes.any((box) => (box.decoration as BoxDecoration).gradient != null); +} + +void main() { + testWidgets('renders a label for every item', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), + ), + ); + + expect(find.text('Chats'), findsOneWidget); + expect(find.text('Saved'), findsOneWidget); + }); + + testWidgets('invokes onTap with the tapped index', (tester) async { + int? tappedIndex; + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (index) => tappedIndex = index), + ), + ); + + await tester.tap(find.text('Saved')); + + expect(tappedIndex, equals(1)); + }); + + testWidgets('throws when fewer than 2 items are provided', (tester) async { + expect( + () => StreamBottomNavBar(items: [_items.first], currentIndex: 0, onTap: (_) {}), + throwsA(isA()), + ); + }); + + group('regular behavior', () { + testWidgets('renders a docked bar with a top border and no gradient', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (_) {}, + behavior: StreamBottomNavBarBehavior.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 behavior', () { + testWidgets('renders a pill container instead of a BottomNavigationBar', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (_) {}, + behavior: StreamBottomNavBarBehavior.floating, + ), + ), + ); + + expect(find.byType(BottomNavigationBar), findsNothing); + expect(find.text('Chats'), findsOneWidget); + }); + + testWidgets('invokes onTap with the tapped index', (tester) async { + int? tappedIndex; + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (index) => tappedIndex = index, + behavior: StreamBottomNavBarBehavior.floating, + ), + ), + ); + + await tester.tap(find.text('Saved')); + + expect(tappedIndex, equals(1)); + }); + + testWidgets('renders a gradient background', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar( + items: _items, + currentIndex: 0, + onTap: (_) {}, + behavior: StreamBottomNavBarBehavior.floating, + ), + ), + ); + + final decoratedBox = tester.widget( + find + .descendant( + of: find.byType(StreamBottomNavBar), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decoratedBox.decoration as BoxDecoration; + expect(decoration.gradient, isA()); + }); + }); + + group('behavior resolution', () { + testWidgets('resolves behavior from StreamBottomNavBarTheme', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBarTheme( + data: const StreamBottomNavBarThemeData( + style: StreamBottomNavBarStyle(behavior: StreamBottomNavBarBehavior.floating), + ), + child: StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), + ), + ), + ); + + expect(_isFloating(tester), isTrue); + }); + + testWidgets('is independent of StreamBottomAppBarTheme', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomAppBarTheme( + data: const StreamBottomAppBarThemeData( + style: StreamBottomAppBarStyle(behavior: StreamBottomAppBarBehavior.floating), + ), + child: StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), + ), + ), + ); + + // The nav bar resolves only from its own theme and StreamAppStyle, 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', ( + tester, + ) async { + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), + appStyle: StreamAppStyle.floating, + ), + ); + + expect(_isFloating(tester), isTrue); + }); + }); + + group('StreamBottomNavBarTheme styling', () { + testWidgets('applies the selected item color to the selected tile', (tester) async { + const selectedColor = Color(0xFF123456); + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBarTheme( + data: const StreamBottomNavBarThemeData( + style: StreamBottomNavBarStyle(selectedItemColor: selectedColor), + ), + child: StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), + ), + ), + ); + + final selectedLabel = tester.widget(find.text('Chats')); + expect(selectedLabel.style?.color, equals(selectedColor)); + }); + }); + + group('semantics', () { + testWidgets('marks each item as a button, flags the selected one, and labels tabs by index', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + _withStreamTheme( + StreamBottomNavBar(items: _items, currentIndex: 0, onTap: (_) {}), + ), + ); + + // Each tab announces its position (localized "Tab N of M"), merged onto + // the tile alongside its label (e.g. "Chats\nTab 1 of 2"). + final selected = find.semantics.byPredicate((n) => n.label.contains('Tab 1 of 2')); + final unselected = find.semantics.byPredicate((n) => n.label.contains('Tab 2 of 2')); + + expect(selected, findsOneWidget); + expect(unselected, findsOneWidget); + + // The selected item is a selected button; the other an unselected button. + expect(selected.evaluate().single, isSemantics(isButton: true, isSelected: true)); + expect(unselected.evaluate().single, isSemantics(isButton: true, isSelected: false)); + + handle.dispose(); + }); + }); +} diff --git a/packages/stream_core_flutter/test/theme/stream_theme_test.dart b/packages/stream_core_flutter/test/theme/stream_theme_test.dart new file mode 100644 index 00000000..fd479e24 --- /dev/null +++ b/packages/stream_core_flutter/test/theme/stream_theme_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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 iosTheme = theme.applyPlatform(TargetPlatform.iOS); + + expect(iosTheme.appStyle, equals(StreamAppStyle.floating)); + expect(iosTheme.brightness, equals(theme.brightness)); + expect(iosTheme.colorScheme, equals(theme.colorScheme)); + }); + + test('regenerates typography and text theme for the given platform', () { + final theme = StreamTheme(platform: TargetPlatform.android); + + final iosTheme = theme.applyPlatform(TargetPlatform.iOS); + + expect(iosTheme.typography.fontSize, equals(StreamFontSize.ios)); + expect(theme.typography.fontSize, equals(StreamFontSize.android)); + }); + }); +}