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 e6c4db4..1c4d21b 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 @@ -78,10 +78,8 @@ import 'package:design_system_gallery/components/controls/stream_video_play_indi as _design_system_gallery_components_controls_stream_video_play_indicator; import 'package:design_system_gallery/components/emoji/stream_emoji_picker_sheet.dart' as _design_system_gallery_components_emoji_stream_emoji_picker_sheet; -import 'package:design_system_gallery/components/header/stream_app_bar.dart' - as _design_system_gallery_components_header_stream_app_bar; -import 'package:design_system_gallery/components/header/stream_sheet_header.dart' - as _design_system_gallery_components_header_stream_sheet_header; +import 'package:design_system_gallery/components/media_viewer/stream_media_viewer.dart' + as _design_system_gallery_components_media_viewer_stream_media_viewer; import 'package:design_system_gallery/components/message/stream_message_annotation.dart' as _design_system_gallery_components_message_stream_message_annotation; import 'package:design_system_gallery/components/message/stream_message_attachment.dart' @@ -118,6 +116,12 @@ import 'package:design_system_gallery/components/sheet/stream_sheet.dart' as _design_system_gallery_components_sheet_stream_sheet; import 'package:design_system_gallery/components/tiles/stream_list_tile.dart' as _design_system_gallery_components_tiles_stream_list_tile; +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_sheet_header.dart' + as _design_system_gallery_components_toolbar_stream_sheet_header; import 'package:design_system_gallery/primitives/colors.dart' as _design_system_gallery_primitives_colors; import 'package:design_system_gallery/primitives/icons.dart' @@ -836,37 +840,22 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookFolder( - name: 'Header', + name: 'Media Viewer', children: [ _widgetbook.WidgetbookComponent( - name: 'StreamAppBar', - useCases: [ - _widgetbook.WidgetbookUseCase( - name: 'Playground', - builder: _design_system_gallery_components_header_stream_app_bar - .buildStreamAppBarPlayground, - ), - _widgetbook.WidgetbookUseCase( - name: 'Showcase', - builder: _design_system_gallery_components_header_stream_app_bar - .buildStreamAppBarShowcase, - ), - ], - ), - _widgetbook.WidgetbookComponent( - name: 'StreamSheetHeader', + name: 'StreamMediaViewer', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_header_stream_sheet_header - .buildStreamSheetHeaderPlayground, + _design_system_gallery_components_media_viewer_stream_media_viewer + .buildStreamMediaViewerPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_header_stream_sheet_header - .buildStreamSheetHeaderShowcase, + _design_system_gallery_components_media_viewer_stream_media_viewer + .buildStreamMediaViewerShowcase, ), ], ), @@ -1201,6 +1190,62 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Toolbar', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamAppBar', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_toolbar_stream_app_bar + .buildStreamAppBarPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_toolbar_stream_app_bar + .buildStreamAppBarShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamBottomAppBar', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_toolbar_stream_bottom_app_bar + .buildStreamBottomAppBarPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_toolbar_stream_bottom_app_bar + .buildStreamBottomAppBarShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamSheetHeader', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_toolbar_stream_sheet_header + .buildStreamSheetHeaderPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_toolbar_stream_sheet_header + .buildStreamSheetHeaderShowcase, + ), + ], + ), + ], + ), ], ), ]; diff --git a/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart b/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart new file mode 100644 index 0000000..f072e62 --- /dev/null +++ b/apps/design_system_gallery/lib/components/media_viewer/stream_media_viewer.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// Public-domain Unsplash sample images used by every launcher. +const _sampleImages = [ + 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=900&q=80', + 'https://images.unsplash.com/photo-1493558103817-58b2924bce98?w=900&q=80', + 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&q=80', + 'https://images.unsplash.com/photo-1470770841072-f978cf4d019e?w=900&q=80', + 'https://images.unsplash.com/photo-1505765050516-f72dcac9c60e?w=900&q=80', +]; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamMediaViewer, + path: '[Components]/Media Viewer', +) +Widget buildStreamMediaViewerPlayground(BuildContext context) { + final showHeader = context.knobs.boolean( + label: 'Show header', + initialValue: true, + description: 'Renders a StreamAppBar as the top chrome.', + ); + + final showFooter = context.knobs.boolean( + label: 'Show footer', + initialValue: true, + description: 'Renders a StreamBottomAppBar as the bottom chrome.', + ); + + final animationMs = context.knobs.double.slider( + label: 'Chrome animation (ms)', + initialValue: 200, + max: 1000, + description: 'Duration of the chrome show/hide animation.', + ); + + final tintChrome = context.knobs.boolean( + label: 'Tint chrome over dark media', + description: + 'Demonstrates StreamMediaViewerThemeData.appBarStyle / ' + 'bottomAppBarStyle — scopes a translucent chrome over the media.', + ); + + return _MediaViewerLauncher( + label: 'Open media viewer', + onPressed: (launchContext) => _push( + launchContext, + StreamMediaViewerTheme( + data: StreamMediaViewerThemeData( + chromeAnimationDuration: Duration(milliseconds: animationMs.round()), + appBarStyle: tintChrome ? const StreamAppBarStyle(backgroundColor: Color(0x55000000)) : null, + bottomAppBarStyle: tintChrome ? const StreamBottomAppBarStyle(backgroundColor: Color(0x55000000)) : null, + ), + child: _PlaygroundMediaViewer( + showHeader: showHeader, + showFooter: showFooter, + ), + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMediaViewer, + path: '[Components]/Media Viewer', +) +Widget buildStreamMediaViewerShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Section( + label: 'Full chrome — header, counter, actions', + description: + 'The default media viewer with both chrome bars. Tap the ' + 'media to toggle them — the background fades to immersive ' + 'black when chrome is hidden.', + launcher: _MediaViewerLauncher( + label: 'Open full-chrome viewer', + onPressed: (launchContext) => _push( + launchContext, + const _PlaygroundMediaViewer(showHeader: true, showFooter: true), + ), + ), + ), + SizedBox(height: spacing.md), + _Section( + label: 'Header only — immersive bottom edge', + description: + 'Footer slot omitted. The media is flush with the bottom ' + 'edge while the header still hosts navigation.', + launcher: _MediaViewerLauncher( + label: 'Open header-only viewer', + onPressed: (launchContext) => _push( + launchContext, + const _PlaygroundMediaViewer(showHeader: true, showFooter: false), + ), + ), + ), + SizedBox(height: spacing.md), + _Section( + label: 'Footer only — immersive top edge', + description: + 'Header slot omitted. The media is flush with the top edge ' + 'while the footer hosts a page counter and actions.', + launcher: _MediaViewerLauncher( + label: 'Open footer-only viewer', + onPressed: (launchContext) => _push( + launchContext, + const _PlaygroundMediaViewer(showHeader: false, showFooter: true), + ), + ), + ), + SizedBox(height: spacing.md), + _Section( + label: 'No chrome — pure media surface', + description: + 'Both chrome slots omitted. The viewer becomes a thin ' + 'wrapper around the media — equivalent to immersive mode ' + 'with chrome permanently hidden.', + launcher: _MediaViewerLauncher( + label: 'Open chrome-less viewer', + onPressed: (launchContext) => _push( + launchContext, + const _PlaygroundMediaViewer(showHeader: false, showFooter: false), + ), + ), + ), + SizedBox(height: spacing.md), + _Section( + label: 'Tinted chrome — scoped appBarStyle / bottomAppBarStyle', + description: + 'StreamMediaViewerThemeData carries optional chrome styles ' + 'scoped to the viewer. Useful for translucent chrome over a ' + 'dark media background without touching app-wide themes.', + launcher: _MediaViewerLauncher( + label: 'Open tinted-chrome viewer', + onPressed: (launchContext) => _push( + launchContext, + const StreamMediaViewerTheme( + data: StreamMediaViewerThemeData( + appBarStyle: StreamAppBarStyle(backgroundColor: Color(0x55000000)), + bottomAppBarStyle: StreamBottomAppBarStyle(backgroundColor: Color(0x55000000)), + ), + child: _PlaygroundMediaViewer( + showHeader: true, + showFooter: true, + ), + ), + ), + ), + ), + SizedBox(height: spacing.md), + _Section( + label: 'Faster chrome animation — 80ms show/hide', + description: + 'chromeAnimationDuration overridden via the theme. Tap the ' + 'media to toggle chrome — the slide/fade lands noticeably ' + 'snappier than the default 200ms.', + launcher: _MediaViewerLauncher( + label: 'Open snappy-chrome viewer', + onPressed: (launchContext) => _push( + launchContext, + const StreamMediaViewerTheme( + data: StreamMediaViewerThemeData( + chromeAnimationDuration: Duration(milliseconds: 80), + ), + child: _PlaygroundMediaViewer( + showHeader: true, + showFooter: true, + ), + ), + ), + ), + ), + ], + ), + ), + ); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +class _MediaViewerLauncher extends StatelessWidget { + const _MediaViewerLauncher({required this.label, required this.onPressed}); + + final String label; + final void Function(BuildContext context) onPressed; + + @override + Widget build(BuildContext context) { + return Center( + child: StreamButton( + onPressed: () => onPressed(context), + child: Text(label), + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.description, + required this.launcher, + }); + + final String label; + final String description; + final Widget launcher; + + @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( + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + padding: EdgeInsets.all(spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + description, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + ), + SizedBox(height: spacing.md), + launcher, + ], + ), + ), + ], + ); + } +} + +class _PlaygroundMediaViewer extends StatefulWidget { + const _PlaygroundMediaViewer({ + required this.showHeader, + required this.showFooter, + }); + + final bool showHeader; + final bool showFooter; + + @override + State<_PlaygroundMediaViewer> createState() => _PlaygroundMediaViewerState(); +} + +class _PlaygroundMediaViewerState extends State<_PlaygroundMediaViewer> { + final _pageController = PageController(); + var _index = 0; + var _showChrome = true; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + return StreamMediaViewer( + showChrome: _showChrome, + header: widget.showHeader + ? StreamAppBar( + title: const Text('You'), + subtitle: const Text('14/01/2026, 16:06'), + trailing: StreamButton.icon( + icon: Icon(icons.more), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + ) + : null, + footer: widget.showFooter + ? StreamBottomAppBar( + leading: StreamButton.icon( + icon: Icon(icons.export), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: Text('${_index + 1} of ${_sampleImages.length}'), + trailing: StreamButton.icon( + icon: Icon(icons.gallery), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + ) + : null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _showChrome = !_showChrome), + child: PageView.builder( + controller: _pageController, + itemCount: _sampleImages.length, + onPageChanged: (page) => setState(() => _index = page), + itemBuilder: (_, i) => InteractiveViewer( + child: Center(child: StreamNetworkImage(_sampleImages[i])), + ), + ), + ), + ); + } +} + +void _push(BuildContext context, Widget page) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => page, + ), + ); +} diff --git a/apps/design_system_gallery/lib/components/header/stream_app_bar.dart b/apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart similarity index 98% rename from apps/design_system_gallery/lib/components/header/stream_app_bar.dart rename to apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart index 38e6b9c..9696329 100644 --- a/apps/design_system_gallery/lib/components/header/stream_app_bar.dart +++ b/apps/design_system_gallery/lib/components/toolbar/stream_app_bar.dart @@ -10,7 +10,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', type: StreamAppBar, - path: '[Components]/Header', + path: '[Components]/Toolbar', ) Widget buildStreamAppBarPlayground(BuildContext context) { final title = context.knobs.stringOrNull( @@ -87,7 +87,7 @@ Widget buildStreamAppBarPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', type: StreamAppBar, - path: '[Components]/Header', + path: '[Components]/Toolbar', ) Widget buildStreamAppBarShowcase(BuildContext context) { final colorScheme = context.streamColorScheme; @@ -179,7 +179,7 @@ Widget buildStreamAppBarShowcase(BuildContext context) { SizedBox(height: spacing.md), // Demonstrates the layout's centred-title behaviour: a narrow icon // leading and a wide text-button trailing have very different - // intrinsic widths, but [StreamHeaderToolbar] reserves symmetric + // intrinsic widths, but [StreamToolbar] reserves symmetric // space around the middle so the title stays geometrically // centred in the bar's full width. _AppBarExample( diff --git a/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart new file mode 100644 index 0000000..0733e2f --- /dev/null +++ b/apps/design_system_gallery/lib/components/toolbar/stream_bottom_app_bar.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamBottomAppBar, + path: '[Components]/Toolbar', +) +Widget buildStreamBottomAppBarPlayground(BuildContext context) { + final title = context.knobs.stringOrNull( + label: 'Title', + initialValue: '1 of 9', + description: 'The centered text. Typically a page counter. Clear to omit.', + ); + + final subtitle = context.knobs.stringOrNull( + label: 'Subtitle', + description: 'Optional second line below the title.', + ); + + final showLeading = context.knobs.boolean( + label: 'Show leading', + initialValue: true, + description: 'Renders an action button at the start edge (e.g. share).', + ); + + final showTrailing = context.knobs.boolean( + label: 'Show trailing', + initialValue: true, + description: 'Renders an action button at the end edge (e.g. gallery).', + ); + + final padding = context.knobs.double.slider( + label: 'Padding', + initialValue: 12, + max: 32, + description: 'Uniform padding around the content row.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 12, + max: 32, + description: 'Horizontal gap between leading, heading, and trailing.', + ); + + final primary = context.knobs.boolean( + label: 'Primary', + initialValue: true, + description: + 'When true, wraps in SafeArea(top: false) so the bar clears ' + 'the system bottom inset (home indicator).', + ); + + return Align( + alignment: Alignment.bottomCenter, + child: StreamBottomAppBar( + primary: primary, + style: StreamBottomAppBarStyle( + padding: EdgeInsets.all(padding), + spacing: spacing, + ), + leading: showLeading + ? StreamButton.icon( + icon: Icon(context.streamIcons.export), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ) + : null, + title: (title != null && title.isNotEmpty) ? Text(title) : null, + subtitle: (subtitle != null && subtitle.isNotEmpty) ? Text(subtitle) : null, + trailing: showTrailing + ? StreamButton.icon( + icon: Icon(context.streamIcons.gallery), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ) + : null, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamBottomAppBar, + path: '[Components]/Toolbar', +) +Widget buildStreamBottomAppBarShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BarExample( + label: 'Counter only', + bar: StreamBottomAppBar(title: const Text('1 of 9')), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Title and subtitle', + bar: StreamBottomAppBar( + title: const Text('1 of 9'), + subtitle: const Text('Tap to share'), + ), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Leading only — trailing reserves a spacer', + bar: StreamBottomAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.export), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('1 of 9'), + ), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Trailing only — leading reserves a spacer', + bar: StreamBottomAppBar( + title: const Text('1 of 9'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.gallery), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + ), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Full layout — share, counter, gallery', + bar: StreamBottomAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.export), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('1 of 9'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.gallery), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + ), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Asymmetric slots — title stays geometrically centred', + bar: StreamBottomAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.export), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('1 of 9'), + trailing: StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + size: StreamButtonSize.small, + onPressed: () {}, + child: const Text('Done'), + ), + ), + ), + SizedBox(height: spacing.md), + _BarExample( + label: 'Per-slot button style propagation via style.leadingStyle / trailingStyle', + bar: StreamBottomAppBar( + style: StreamBottomAppBarStyle( + leadingStyle: StreamButtonThemeStyle.from( + backgroundColor: colorScheme.backgroundSurfaceSubtle, + foregroundColor: colorScheme.textPrimary, + ), + trailingStyle: StreamButtonThemeStyle.from( + backgroundColor: colorScheme.accentPrimary, + foregroundColor: colorScheme.textOnAccent, + ), + ), + leading: StreamButton.icon( + icon: Icon(context.streamIcons.export), + onPressed: () {}, + ), + title: const Text('Tap an action'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + onPressed: () {}, + ), + ), + ), + ], + ), + ), + ); +} + +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), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: bar, + ), + ], + ); + } +} diff --git a/apps/design_system_gallery/lib/components/header/stream_sheet_header.dart b/apps/design_system_gallery/lib/components/toolbar/stream_sheet_header.dart similarity index 99% rename from apps/design_system_gallery/lib/components/header/stream_sheet_header.dart rename to apps/design_system_gallery/lib/components/toolbar/stream_sheet_header.dart index 8adfe61..22ddfb5 100644 --- a/apps/design_system_gallery/lib/components/header/stream_sheet_header.dart +++ b/apps/design_system_gallery/lib/components/toolbar/stream_sheet_header.dart @@ -10,7 +10,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', type: StreamSheetHeader, - path: '[Components]/Header', + path: '[Components]/Toolbar', ) Widget buildStreamSheetHeaderPlayground(BuildContext context) { final title = context.knobs.stringOrNull( @@ -87,7 +87,7 @@ Widget buildStreamSheetHeaderPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', type: StreamSheetHeader, - path: '[Components]/Header', + path: '[Components]/Toolbar', ) Widget buildStreamSheetHeaderShowcase(BuildContext context) { final colorScheme = context.streamColorScheme; @@ -179,7 +179,7 @@ Widget buildStreamSheetHeaderShowcase(BuildContext context) { SizedBox(height: spacing.md), // Demonstrates the layout's centred-title behaviour: a narrow icon // leading and a wide text-button trailing have very different - // intrinsic widths, but [StreamHeaderToolbar] reserves symmetric + // intrinsic widths, but [StreamToolbar] reserves symmetric // space around the middle so the title stays geometrically // centred in the bar's full width. _HeaderExample( diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 612bcb7..fb9e085 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -8,7 +8,9 @@ - Added `trailing` slot to `StreamMessageAnnotation`, with matching `trailingTextStyle`/`trailingTextColor` on `StreamMessageAnnotationStyle`. - Added `StreamTapTargetPadding`, a reusable primitive that grows a child's layout and hit-test area to a configurable `minSize` without changing its visual size, with a directional `alignment` that controls which direction the extra tap area extends into. - Added `StreamSheetHeader` component and `StreamSheetHeaderTheme` for bottom-sheet and modal headers, with platform-aware auto-implied dismissal based on the enclosing route. -- Added `StreamHeaderToolbar`, a three-slot layout primitive shared by `StreamAppBar` and `StreamSheetHeader` that keeps the title geometrically centred even when leading and trailing widths differ. +- Added `StreamToolbar`, a three-slot layout primitive shared by `StreamAppBar`, `StreamBottomAppBar`, and `StreamSheetHeader` that keeps the title geometrically centred even when leading and trailing widths differ. +- Added `StreamBottomAppBar` and `StreamBottomAppBarTheme`, the bottom counterpart to `StreamAppBar` with the same three-slot (`leading`/`heading`/`trailing`) layout, optional title + subtitle, top hairline border, `SafeArea(top: false)` when primary, and per-slot button style propagation. +- Added `StreamMediaViewer` and `StreamMediaViewerTheme`, a full-screen media chrome controller that composes optional header/footer chrome over an edge-to-edge child, animates the chrome in/out via `showChrome`, and fades to an immersive background when hidden. The theme exposes scoped `appBarStyle`/`bottomAppBarStyle` so descendant chrome bars can be tinted (e.g. light-on-dark over dark media) without touching app-wide themes. - Added `StreamSheet`, `StreamSheetDragHandle`, `StreamSheetRoute`, `StreamSheetTransition` and the `showStreamSheet` helper — Stream-styled modal bottom sheets with scroll-aware drag-to-dismiss and stacking support. `StreamSheet` can also be used standalone outside the modal route. - Added `StreamSheetTheme` and `StreamSheetThemeData` (`StreamTheme.sheetTheme`) for theming `StreamSheet` and modal sheets opened with `showStreamSheet`. - `StreamEmojiPickerSheet.show` now resolves its background color and border radius from the ambient `StreamSheetTheme` so the picker visually matches other Stream-styled sheets by default. diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 6edf46a..a185058 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -37,10 +37,8 @@ export 'components/controls/stream_video_play_indicator.dart'; export 'components/emoji/data/stream_emoji_data.dart'; export 'components/emoji/data/stream_supported_emojis.dart'; export 'components/emoji/stream_emoji_picker_sheet.dart'; -export 'components/header/stream_app_bar.dart' hide DefaultStreamAppBar; -export 'components/header/stream_header_toolbar.dart'; -export 'components/header/stream_sheet_header.dart' hide DefaultStreamSheetHeader; export 'components/list/stream_list_tile.dart' hide DefaultStreamListTile; +export 'components/media_viewer/stream_media_viewer.dart' hide DefaultStreamMediaViewer; export 'components/message/stream_message_annotation.dart' hide DefaultStreamMessageAnnotation; export 'components/message/stream_message_attachment.dart'; export 'components/message/stream_message_bubble.dart' hide DefaultStreamMessageBubble; @@ -58,4 +56,8 @@ export 'components/message_layout/stream_message_stack_position.dart'; export 'components/reaction/stream_reaction_picker.dart' hide DefaultStreamReactionPicker; export 'components/reaction/stream_reactions.dart' hide DefaultStreamReactions; export 'components/sheet/stream_sheet.dart'; +export 'components/toolbar/stream_app_bar.dart' hide DefaultStreamAppBar; +export 'components/toolbar/stream_bottom_app_bar.dart' hide DefaultStreamBottomAppBar; +export 'components/toolbar/stream_sheet_header.dart' hide DefaultStreamSheetHeader; +export 'components/toolbar/stream_toolbar.dart'; export 'factory/stream_component_factory.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart b/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart new file mode 100644 index 0000000..c7ee4e6 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/media_viewer/stream_media_viewer.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core/stream_core.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_app_bar_theme.dart'; +import '../../theme/components/stream_bottom_app_bar_theme.dart'; +import '../../theme/components/stream_media_viewer_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import '../toolbar/stream_app_bar.dart'; +import '../toolbar/stream_bottom_app_bar.dart'; + +/// A full-screen surface for browsing media (images, videos, etc.). +/// +/// Composes an optional [header], [footer], and [child] media area, and +/// animates the chrome in and out via [showChrome]. The [child] is +/// typically a [PageView] of image / video players, [header] a +/// [StreamAppBar], and [footer] a [StreamBottomAppBar] — none are +/// required. +/// +/// {@tool snippet} +/// +/// Browse a list of attachments with chrome that hides on tap: +/// +/// ```dart +/// StreamMediaViewer( +/// showChrome: _showChrome, +/// header: StreamAppBar(title: const Text('Photo')), +/// footer: StreamBottomAppBar(title: Text('${i + 1} of $n')), +/// child: GestureDetector( +/// onTap: () => setState(() => _showChrome = !_showChrome), +/// child: PageView.builder(/* ... */), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaViewerTheme], for theming via the widget tree. +/// * [StreamAppBar], the top chrome typically used as [header]. +/// * [StreamBottomAppBar], the bottom chrome typically used as [footer]. +class StreamMediaViewer extends StatelessWidget { + /// Creates a Stream media viewer. + StreamMediaViewer({ + super.key, + required Widget child, + PreferredSizeWidget? header, + PreferredSizeWidget? footer, + bool showChrome = true, + }) : props = .new( + child: child, + header: header, + footer: footer, + showChrome: showChrome, + ); + + /// The properties that configure this media viewer. + final StreamMediaViewerProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).mediaViewer; + if (builder != null) return builder(context, props); + return DefaultStreamMediaViewer(props: props); + } +} + +/// Properties for configuring a [StreamMediaViewer]. +/// +/// See also: +/// +/// * [StreamMediaViewer], which uses these properties. +class StreamMediaViewerProps { + /// Creates properties for a media viewer. + const StreamMediaViewerProps({ + required this.child, + this.header, + this.footer, + this.showChrome = true, + }); + + /// The media content. Inset to fit between [header] and [footer] (plus + /// the top / bottom safe-area insets) so chrome never overlaps it. + final Widget child; + + /// The top chrome — typically a [StreamAppBar]. Slides off-screen + /// when [showChrome] flips to false. + final PreferredSizeWidget? header; + + /// The bottom chrome — typically a [StreamBottomAppBar]. Slides + /// off-screen when [showChrome] flips to false. + final PreferredSizeWidget? footer; + + /// Whether the chrome (header / footer) is visible. + /// + /// When false, chrome slides off-screen and the background fades to + /// the immersive colour. The caller owns this state — typically a + /// tap on the media toggles it. + final bool showChrome; +} + +/// The default implementation of [StreamMediaViewer]. +/// +/// See also: +/// +/// * [StreamMediaViewer], the public API widget. +/// * [StreamMediaViewerProps], which configures this widget. +class DefaultStreamMediaViewer extends StatelessWidget { + /// Creates a default media viewer with the given [props]. + const DefaultStreamMediaViewer({super.key, required this.props}); + + /// The properties that configure this media viewer. + final StreamMediaViewerProps props; + + @override + Widget build(BuildContext context) { + final theme = context.streamMediaViewerTheme; + final defaults = _StreamMediaViewerDefaults(context); + + final showChrome = props.showChrome; + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveImmersiveBackgroundColor = theme.immersiveBackgroundColor ?? defaults.immersiveBackgroundColor; + final effectiveDuration = theme.chromeAnimationDuration ?? defaults.chromeAnimationDuration; + final effectiveAppBarStyle = theme.appBarStyle ?? defaults.appBarStyle; + final effectiveBottomAppBarStyle = theme.bottomAppBarStyle ?? defaults.bottomAppBarStyle; + + // Scopes optional chrome styles so descendant app bars resolve to + // them instead of the ambient theme. + Widget scopeChromeTheme(Widget child) { + var scoped = child; + if (effectiveAppBarStyle case final style?) { + scoped = StreamAppBarTheme( + data: StreamAppBarThemeData(style: style), + child: scoped, + ); + } + if (effectiveBottomAppBarStyle case final style?) { + scoped = StreamBottomAppBarTheme( + data: StreamBottomAppBarThemeData(style: style), + child: scoped, + ); + } + return scoped; + } + + final mediaQueryPadding = MediaQuery.paddingOf(context); + final headerInset = props.header?.let((it) => it.preferredSize.height + mediaQueryPadding.top) ?? 0.0; + final footerInset = props.footer?.let((it) => it.preferredSize.height + mediaQueryPadding.bottom) ?? 0.0; + + return AnimatedContainer( + curve: Curves.easeInOut, + duration: effectiveDuration, + color: showChrome ? effectiveBackgroundColor : effectiveImmersiveBackgroundColor, + child: scopeChromeTheme( + Stack( + fit: .expand, + children: [ + AnimatedPadding( + duration: effectiveDuration, + curve: Curves.easeInOut, + padding: .only(top: headerInset, bottom: footerInset), + child: props.child, + ), + if (props.header case final header?) + Positioned( + top: 0, + left: 0, + right: 0, + child: _ChromeSlot( + duration: effectiveDuration, + visible: showChrome, + slideOffset: const Offset(0, -1), + child: header, + ), + ), + if (props.footer case final footer?) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _ChromeSlot( + duration: effectiveDuration, + visible: showChrome, + slideOffset: const Offset(0, 1), + child: footer, + ), + ), + ], + ), + ), + ); + } +} + +class _ChromeSlot extends StatelessWidget { + const _ChromeSlot({ + required this.duration, + required this.visible, + required this.slideOffset, + required this.child, + }); + + final Duration duration; + final bool visible; + final Offset slideOffset; + final Widget child; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + // Let taps fall through to the media when chrome is hidden, so + // the caller's tap-to-toggle gesture can bring it back. + ignoring: !visible, + child: AnimatedSlide( + offset: visible ? .zero : slideOffset, + duration: duration, + curve: Curves.easeInOut, + child: AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: duration, + curve: Curves.easeInOut, + child: child, + ), + ), + ); + } +} + +class _StreamMediaViewerDefaults extends StreamMediaViewerThemeData { + _StreamMediaViewerDefaults(this.context) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + Color get immersiveBackgroundColor => StreamColors.black; + + @override + Duration get chromeAnimationDuration => kThemeAnimationDuration; +} diff --git a/packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_app_bar.dart similarity index 95% rename from packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart rename to packages/stream_core_flutter/lib/src/components/toolbar/stream_app_bar.dart index bbff983..9f200a2 100644 --- a/packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_app_bar.dart @@ -8,7 +8,7 @@ import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_theme_extensions.dart'; import '../buttons/stream_button.dart'; -import 'stream_header_toolbar.dart'; +import 'stream_toolbar.dart'; /// A top-of-screen header for full-page surfaces in the Stream design system. /// @@ -17,9 +17,9 @@ import 'stream_header_toolbar.dart'; /// typically a back button on the leading side and a primary action on the /// trailing side. /// -/// The heading occupies the flexible center of the row, with a 48×48 spacer -/// reserved opposite a lone [leading] or [trailing] so the title stays -/// visually balanced. +/// The heading occupies the flexible center of the row, with the wider of +/// [leading] / [trailing] mirrored on the opposite side so the title stays +/// geometrically centred. /// /// When [leading] is null and [automaticallyImplyLeading] is true (the /// default), a dismissal button is inserted if the enclosing route can pop: @@ -86,7 +86,7 @@ class StreamAppBar extends StatelessWidget implements PreferredSizeWidget { final StreamAppBarProps props; @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { @@ -120,7 +120,7 @@ class StreamAppBarProps { /// A widget to display before the [title]. /// /// Typically a back button. The caller is responsible for the widget's - /// own hit area; the app bar only reserves a 48×48 slot for symmetry. + /// own size and hit area. /// /// When null and [automaticallyImplyLeading] is true, a default dismissal /// button is inserted if the enclosing route can pop — a cross on @@ -153,8 +153,7 @@ class StreamAppBarProps { /// A widget to display after the [title]. /// /// Typically a primary or overflow action. The caller is responsible for - /// the widget's own hit area; the app bar only reserves a 48×48 slot for - /// symmetry. + /// the widget's own size and hit area. final Widget? trailing; /// Whether this app bar is the topmost chrome of its surface. @@ -291,8 +290,8 @@ class DefaultStreamAppBar extends StatelessWidget { // (e.g. when placed directly inside a [Column] or a [Container] // rather than in a [Scaffold.appBar] slot). Widget bar = SizedBox( - height: kStreamHeaderHeight, - child: StreamHeaderToolbar( + height: kStreamToolbarHeight, + child: StreamToolbar( padding: effectivePadding, spacing: effectiveSpacing, leading: leading, diff --git a/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart new file mode 100644 index 0000000..fab948c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_bottom_app_bar.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_bottom_app_bar_theme.dart'; +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_theme_extensions.dart'; +import 'stream_toolbar.dart'; + +/// A bottom-of-screen toolbar for full-page surfaces in the Stream design +/// system. +/// +/// [StreamBottomAppBar] arranges an optional centered [title] between +/// optional [leading] and [trailing] widget slots — typically a primary +/// action on either side (e.g. a share button and a gallery toggle). +/// +/// The title occupies the flexible center of the row, with the wider of +/// [leading] / [trailing] mirrored on the opposite side so the title stays +/// geometrically centred. +/// +/// A hairline `borderSubtle` border is drawn along the top edge to separate +/// the bar from page content — it's part of the bar's identity rather than +/// a configurable divider. +/// +/// [StreamBottomAppBar] implements [PreferredSizeWidget] so it can be passed +/// directly to [Scaffold.bottomNavigationBar]. +/// +/// {@tool snippet} +/// +/// Use as a [Scaffold.bottomNavigationBar] with a centered counter — leading +/// and trailing icons sit flush at the screen edges: +/// +/// ```dart +/// Scaffold( +/// bottomNavigationBar: StreamBottomAppBar( +/// leading: StreamButton.icon( +/// icon: Icon(context.streamIcons.export), +/// onPressed: _shareImage, +/// ), +/// title: const Text('1 of 9'), +/// trailing: StreamButton.icon( +/// icon: Icon(context.streamIcons.gallery), +/// onPressed: _openGrid, +/// ), +/// ), +/// body: ..., +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamBottomAppBar] uses [StreamBottomAppBarThemeData] for default +/// styling — colours, padding, spacing, title text style, and per-slot +/// button style propagation. Defaults are derived from [StreamColorScheme], +/// [StreamTextTheme], and [StreamSpacing]. +/// +/// See also: +/// +/// * [StreamBottomAppBarThemeData], for customizing appearance globally. +/// * [StreamBottomAppBarTheme], for overriding theme in a subtree. +/// * [StreamAppBar], the equivalent for top-level screen chrome. +/// * [DefaultStreamBottomAppBar], the default visual implementation. +class StreamBottomAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Creates a Stream bottom app bar. + StreamBottomAppBar({ + super.key, + Widget? leading, + Widget? title, + Widget? subtitle, + Widget? trailing, + bool primary = true, + StreamBottomAppBarStyle? style, + }) : props = .new( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, + ); + + /// The properties that configure this bottom app bar. + final StreamBottomAppBarProps props; + + @override + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).bottomAppBar; + if (builder != null) return builder(context, props); + return DefaultStreamBottomAppBar(props: props); + } +} + +/// Properties for configuring a [StreamBottomAppBar]. +/// +/// This class holds all configuration options for a bottom app bar, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamBottomAppBar], which uses these properties. +/// * [DefaultStreamBottomAppBar], the default implementation. +class StreamBottomAppBarProps { + /// Creates properties for a bottom app bar. + const StreamBottomAppBarProps({ + this.leading, + this.title, + this.subtitle, + this.trailing, + this.primary = true, + this.style, + }); + + /// A widget to display before the [title]. + /// + /// Typically a primary action button. The caller is responsible for the + /// widget's own size and hit area. + final Widget? leading; + + /// The primary content of the bar. + /// + /// Typically a [Text] widget — for example, a page counter like `1 of 9`. + /// Its text style is resolved from [StreamBottomAppBarStyle.titleTextStyle] + /// (defaults to `textTheme.headingSm` on `colorScheme.textPrimary`). + final Widget? title; + + /// Additional content displayed below the [title]. + /// + /// Typically a [Text] widget — for example, a hint label below a + /// page counter. Its text style is resolved from + /// [StreamBottomAppBarStyle.subtitleTextStyle] (defaults to + /// `textTheme.captionDefault` on `colorScheme.textSecondary`). + final Widget? subtitle; + + /// A widget to display after the [title]. + /// + /// Typically a primary action button. The caller is responsible for the + /// widget's own size and hit area. + final Widget? trailing; + + /// Whether this bar is the bottommost chrome of its surface. + /// + /// When true (the default), the bar wraps itself in a + /// `SafeArea(top: false)` so it clears the system bottom inset + /// (home indicator) and horizontal insets. + /// + /// Set to false when the bar isn't at the bottom of its surface (e.g. + /// inside a sub-section of a page that has already consumed the + /// bottom inset) so it doesn't double-pad. + final bool primary; + + /// The visual style applied to this bar. + /// + /// Resolution order per field: this [style] → ambient + /// [StreamBottomAppBarTheme] → token-backed defaults. + final StreamBottomAppBarStyle? style; +} + +/// The default implementation of [StreamBottomAppBar]. +/// +/// This widget renders the bottom app bar with theming support from +/// [StreamBottomAppBarTheme]. It's used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// The title slot is centred in the bar's full inner width via +/// [StreamToolbar], which reserves symmetric space around the middle +/// so an asymmetric leading and trailing don't shift the title off-centre. +/// +/// See also: +/// +/// * [StreamBottomAppBar], the public API widget. +/// * [StreamBottomAppBarProps], which configures this widget. +class DefaultStreamBottomAppBar extends StatelessWidget { + /// Creates a default bottom app bar with the given [props]. + const DefaultStreamBottomAppBar({super.key, required this.props}); + + /// The properties that configure this bottom app bar. + final StreamBottomAppBarProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final style = context.streamBottomAppBarTheme.style?.merge(props.style) ?? props.style; + final defaults = _StreamBottomAppBarStyleDefaults(context); + + final effectiveBackgroundColor = style?.backgroundColor ?? defaults.backgroundColor; + final effectivePadding = style?.padding ?? defaults.padding; + final effectiveSpacing = style?.spacing ?? defaults.spacing; + final effectiveTitleTextStyle = style?.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleTextStyle = style?.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectiveLeadingStyle = style?.leadingStyle ?? defaults.leadingStyle; + final effectiveTrailingStyle = style?.trailingStyle ?? defaults.trailingStyle; + + var leading = props.leading; + var trailing = props.trailing; + + // Propagate leading/trailing button style to any StreamButton in the + // slot via a scoped StreamButtonTheme covering every style/type + // combination. Per-instance themeStyle still wins via merge. + if (leading != null && effectiveLeadingStyle != null) { + leading = StreamButtonTheme( + data: .all(.all(effectiveLeadingStyle)), + child: leading, + ); + } + + if (trailing != null && effectiveTrailingStyle != null) { + trailing = StreamButtonTheme( + data: .all(.all(effectiveTrailingStyle)), + child: trailing, + ); + } + + Widget? titleWidget; + if (props.title case final title?) { + titleWidget = AnimatedDefaultTextStyle( + style: effectiveTitleTextStyle, + textAlign: TextAlign.center, + duration: kThemeChangeDuration, + child: title, + ); + } + + Widget? subtitleWidget; + if (props.subtitle case final subtitle?) { + subtitleWidget = AnimatedDefaultTextStyle( + style: effectiveSubtitleTextStyle, + textAlign: TextAlign.center, + duration: kThemeChangeDuration, + child: subtitle, + ); + } + + Widget? middle; + if (titleWidget != null || subtitleWidget != null) { + middle = Column( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [?titleWidget, ?subtitleWidget], + ); + } + + // The bar advertises a fixed height via [PreferredSizeWidget]; the + // [SizedBox] enforces it for callers that don't honour the contract + // (e.g. when placed directly inside a [Column] or a [Container] + // rather than in a [Scaffold.bottomNavigationBar] slot). + Widget bar = SizedBox( + height: kStreamToolbarHeight, + child: StreamToolbar( + padding: effectivePadding, + spacing: effectiveSpacing, + leading: leading, + middle: middle, + trailing: trailing, + ), + ); + + if (props.primary) { + bar = SafeArea(top: false, child: bar); + } + + // The bar's top edge is intentionally a hairline border in the design + // system's `borderSubtle` colour — part of the bar's identity, not a + // configurable divider. + return DecoratedBox( + decoration: BoxDecoration( + color: effectiveBackgroundColor, + border: Border( + top: BorderSide(color: context.streamColorScheme.borderSubtle), + ), + ), + child: bar, + ); + } +} + +// Default style values for [StreamBottomAppBar]. +// +// These defaults are used when no explicit value is provided via constructor +// parameters or [StreamBottomAppBarStyle]. The defaults are context-aware +// and use values from [StreamColorScheme], [StreamTextTheme], and +// [StreamSpacing]. +class _StreamBottomAppBarStyleDefaults extends StreamBottomAppBarStyle { + _StreamBottomAppBarStyleDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + Color get backgroundColor => _colorScheme.backgroundElevation1; + + @override + double get spacing => _spacing.sm; + + @override + EdgeInsetsGeometry get padding => .all(_spacing.sm); + + @override + TextStyle get titleTextStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleTextStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textSecondary); +} diff --git a/packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_sheet_header.dart similarity index 96% rename from packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart rename to packages/stream_core_flutter/lib/src/components/toolbar/stream_sheet_header.dart index 0c0231e..588533e 100644 --- a/packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_sheet_header.dart @@ -9,7 +9,7 @@ import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_theme_extensions.dart'; import '../buttons/stream_button.dart'; import '../sheet/stream_sheet.dart'; -import 'stream_header_toolbar.dart'; +import 'stream_toolbar.dart'; /// A header for bottom sheets, modals, and dialogs in the Stream design /// system. @@ -19,9 +19,9 @@ import 'stream_header_toolbar.dart'; /// typically a close button on the leading side and a confirm action on /// the trailing side. /// -/// The heading occupies the flexible center of the row, with a 48×48 -/// spacer reserved opposite a lone [leading] or [trailing] so the title -/// stays visually balanced. +/// The heading occupies the flexible center of the row, with the wider of +/// [leading] / [trailing] mirrored on the opposite side so the title stays +/// geometrically centred. /// /// When [leading] is null and [automaticallyImplyLeading] is true (the /// default), a dismissal button is inserted if the enclosing route can @@ -112,7 +112,7 @@ class StreamSheetHeader extends StatelessWidget implements PreferredSizeWidget { final StreamSheetHeaderProps props; @override - Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { @@ -146,8 +146,7 @@ class StreamSheetHeaderProps { /// A widget to display before the [title]. /// /// Typically a close button or avatar. The caller is responsible for the - /// widget's own hit area; the header only reserves a 48×48 slot for - /// symmetry. + /// widget's own size and hit area. /// /// When null and [automaticallyImplyLeading] is true, a default dismissal /// button is inserted if the enclosing route can pop — see @@ -179,8 +178,7 @@ class StreamSheetHeaderProps { /// A widget to display after the [title]. /// /// Typically a confirm or overflow action. The caller is responsible for - /// the widget's own hit area; the header only reserves a 48×48 slot for - /// symmetry. + /// the widget's own size and hit area. final Widget? trailing; /// Whether this header is the topmost chrome of its surface. @@ -208,7 +206,7 @@ class StreamSheetHeaderProps { /// implementation in [StreamComponentFactory]. /// /// The title slot is centred in the header's full inner width via -/// [StreamHeaderToolbar], which reserves symmetric space around the +/// [StreamToolbar], which reserves symmetric space around the /// middle so an asymmetric leading and trailing don't shift the title /// off-centre. /// @@ -361,8 +359,8 @@ class DefaultStreamSheetHeader extends StatelessWidget { // (sheet headers usually live inside a [Column], not a slot that reads // [PreferredSizeWidget.preferredSize]). Widget header = SizedBox( - height: kStreamHeaderHeight, - child: StreamHeaderToolbar( + height: kStreamToolbarHeight, + child: StreamToolbar( padding: effectivePadding, spacing: effectiveSpacing, leading: leading, diff --git a/packages/stream_core_flutter/lib/src/components/header/stream_header_toolbar.dart b/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar.dart similarity index 86% rename from packages/stream_core_flutter/lib/src/components/header/stream_header_toolbar.dart rename to packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar.dart index 30a6e5a..9ba21ce 100644 --- a/packages/stream_core_flutter/lib/src/components/header/stream_header_toolbar.dart +++ b/packages/stream_core_flutter/lib/src/components/toolbar/stream_toolbar.dart @@ -2,12 +2,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -/// Default height of [StreamAppBar] and [StreamSheetHeader] per the Figma -/// design system. -const double kStreamHeaderHeight = 72; +/// Default height of [StreamAppBar], [StreamBottomAppBar], and +/// [StreamSheetHeader] per the Stream design system. +const double kStreamToolbarHeight = 72; -/// Three-slot horizontal layout shared by [StreamAppBar] and -/// [StreamSheetHeader]. +/// Three-slot horizontal layout shared by [StreamAppBar], +/// [StreamBottomAppBar], and [StreamSheetHeader]. /// /// Lays out an optional [leading] / [trailing] flush against the bar's start /// and end edges (after [padding]) and an optional [middle] centred in the @@ -18,13 +18,13 @@ const double kStreamHeaderHeight = 72; /// Each slot is vertically centred inside the available content height. The /// toolbar takes its size from the parent's tight height constraint — /// callers are responsible for sitting in a fixed-height slot (e.g. via -/// [PreferredSize] or a [SizedBox] using [kStreamHeaderHeight]). +/// [PreferredSize] or a [SizedBox] using [kStreamToolbarHeight]). /// /// [padding] is the bar-edge padding around all three slots; [spacing] is /// the minimum gap reserved between the middle and either side slot. -class StreamHeaderToolbar extends StatelessWidget { - /// Creates a header toolbar layout with the given slots. - const StreamHeaderToolbar({ +class StreamToolbar extends StatelessWidget { + /// Creates a toolbar layout with the given slots. + const StreamToolbar({ super.key, this.leading, this.middle, @@ -52,7 +52,7 @@ class StreamHeaderToolbar extends StatelessWidget { Widget build(BuildContext context) { final textDirection = Directionality.of(context); return CustomMultiChildLayout( - delegate: _StreamHeaderToolbarLayout( + delegate: _StreamToolbarLayout( spacing: spacing, textDirection: textDirection, padding: padding.resolve(textDirection), @@ -68,8 +68,8 @@ class StreamHeaderToolbar extends StatelessWidget { enum _Slot { leading, middle, trailing } -class _StreamHeaderToolbarLayout extends MultiChildLayoutDelegate { - _StreamHeaderToolbarLayout({ +class _StreamToolbarLayout extends MultiChildLayoutDelegate { + _StreamToolbarLayout({ required this.padding, required this.spacing, required this.textDirection, @@ -124,7 +124,7 @@ class _StreamHeaderToolbarLayout extends MultiChildLayoutDelegate { } @override - bool shouldRelayout(covariant _StreamHeaderToolbarLayout oldDelegate) { + bool shouldRelayout(covariant _StreamToolbarLayout oldDelegate) { return padding != oldDelegate.padding || spacing != oldDelegate.spacing || textDirection != oldDelegate.textDirection; 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 d9e643e..624b40a 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 @@ -133,6 +133,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { factory StreamComponentBuilders({ StreamComponentBuilder? appBar, StreamComponentBuilder? avatar, + StreamComponentBuilder? bottomAppBar, StreamComponentBuilder? avatarGroup, StreamComponentBuilder? avatarStack, StreamComponentBuilder? badgeCount, @@ -149,6 +150,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? fileTypeIcon, StreamComponentBuilder? listTile, StreamComponentBuilder? loadingSpinner, + StreamComponentBuilder? mediaViewer, StreamComponentBuilder? messageAnnotation, StreamComponentBuilder? messageBubble, StreamComponentBuilder? messageComposerAttachment, @@ -183,6 +185,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { return .raw( appBar: appBar, avatar: avatar, + bottomAppBar: bottomAppBar, avatarGroup: avatarGroup, avatarStack: avatarStack, badgeCount: badgeCount, @@ -199,6 +202,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { fileTypeIcon: fileTypeIcon, listTile: listTile, loadingSpinner: loadingSpinner, + mediaViewer: mediaViewer, messageAnnotation: messageAnnotation, messageBubble: messageBubble, messageComposerAttachment: messageComposerAttachment, @@ -234,6 +238,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { const StreamComponentBuilders.raw({ required this.appBar, required this.avatar, + required this.bottomAppBar, required this.avatarGroup, required this.avatarStack, required this.badgeCount, @@ -250,6 +255,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.fileTypeIcon, required this.listTile, required this.loadingSpinner, + required this.mediaViewer, required this.messageAnnotation, required this.messageBubble, required this.messageComposerAttachment, @@ -309,6 +315,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamAvatar] uses [DefaultStreamAvatar]. final StreamComponentBuilder? avatar; + /// Custom builder for bottom app bar widgets. + /// + /// When null, [StreamBottomAppBar] uses [DefaultStreamBottomAppBar]. + final StreamComponentBuilder? bottomAppBar; + /// Custom builder for avatar group widgets. /// /// When null, [StreamAvatarGroup] uses [DefaultStreamAvatarGroup]. @@ -390,6 +401,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamLoadingSpinner] uses [DefaultStreamLoadingSpinner]. final StreamComponentBuilder? loadingSpinner; + /// Custom builder for media viewer widgets. + /// + /// When null, [StreamMediaViewer] uses [DefaultStreamMediaViewer]. + final StreamComponentBuilder? mediaViewer; + /// Custom builder for message annotation widgets. /// /// When null, [StreamMessageAnnotation] uses [DefaultStreamMessageAnnotation]. 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 2ad6a68..123dfc5 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 @@ -33,6 +33,7 @@ mixin _$StreamComponentBuilders { extensions: t < 0.5 ? a.extensions : b.extensions, appBar: t < 0.5 ? a.appBar : b.appBar, avatar: t < 0.5 ? a.avatar : b.avatar, + bottomAppBar: t < 0.5 ? a.bottomAppBar : b.bottomAppBar, avatarGroup: t < 0.5 ? a.avatarGroup : b.avatarGroup, avatarStack: t < 0.5 ? a.avatarStack : b.avatarStack, badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, @@ -49,6 +50,7 @@ mixin _$StreamComponentBuilders { fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, listTile: t < 0.5 ? a.listTile : b.listTile, loadingSpinner: t < 0.5 ? a.loadingSpinner : b.loadingSpinner, + mediaViewer: t < 0.5 ? a.mediaViewer : b.mediaViewer, messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, messageComposerAttachment: t < 0.5 @@ -99,6 +101,7 @@ mixin _$StreamComponentBuilders { Map>? extensions, Widget Function(BuildContext, StreamAppBarProps)? appBar, Widget Function(BuildContext, StreamAvatarProps)? avatar, + Widget Function(BuildContext, StreamBottomAppBarProps)? bottomAppBar, Widget Function(BuildContext, StreamAvatarGroupProps)? avatarGroup, Widget Function(BuildContext, StreamAvatarStackProps)? avatarStack, Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, @@ -118,6 +121,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamListTileProps)? listTile, Widget Function(BuildContext, StreamLoadingSpinnerProps)? loadingSpinner, + Widget Function(BuildContext, StreamMediaViewerProps)? mediaViewer, Widget Function(BuildContext, StreamMessageAnnotationProps)? messageAnnotation, Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, @@ -172,6 +176,7 @@ mixin _$StreamComponentBuilders { extensions: extensions ?? _this.extensions, appBar: appBar ?? _this.appBar, avatar: avatar ?? _this.avatar, + bottomAppBar: bottomAppBar ?? _this.bottomAppBar, avatarGroup: avatarGroup ?? _this.avatarGroup, avatarStack: avatarStack ?? _this.avatarStack, badgeCount: badgeCount ?? _this.badgeCount, @@ -188,6 +193,7 @@ mixin _$StreamComponentBuilders { fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, listTile: listTile ?? _this.listTile, loadingSpinner: loadingSpinner ?? _this.loadingSpinner, + mediaViewer: mediaViewer ?? _this.mediaViewer, messageAnnotation: messageAnnotation ?? _this.messageAnnotation, messageBubble: messageBubble ?? _this.messageBubble, messageComposerAttachment: @@ -245,6 +251,7 @@ mixin _$StreamComponentBuilders { extensions: other.extensions, appBar: other.appBar, avatar: other.avatar, + bottomAppBar: other.bottomAppBar, avatarGroup: other.avatarGroup, avatarStack: other.avatarStack, badgeCount: other.badgeCount, @@ -261,6 +268,7 @@ mixin _$StreamComponentBuilders { fileTypeIcon: other.fileTypeIcon, listTile: other.listTile, loadingSpinner: other.loadingSpinner, + mediaViewer: other.mediaViewer, messageAnnotation: other.messageAnnotation, messageBubble: other.messageBubble, messageComposerAttachment: other.messageComposerAttachment, @@ -310,6 +318,7 @@ mixin _$StreamComponentBuilders { return _other.extensions == _this.extensions && _other.appBar == _this.appBar && _other.avatar == _this.avatar && + _other.bottomAppBar == _this.bottomAppBar && _other.avatarGroup == _this.avatarGroup && _other.avatarStack == _this.avatarStack && _other.badgeCount == _this.badgeCount && @@ -326,6 +335,7 @@ mixin _$StreamComponentBuilders { _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && _other.loadingSpinner == _this.loadingSpinner && + _other.mediaViewer == _this.mediaViewer && _other.messageAnnotation == _this.messageAnnotation && _other.messageBubble == _this.messageBubble && _other.messageComposerAttachment == _this.messageComposerAttachment && @@ -370,6 +380,7 @@ mixin _$StreamComponentBuilders { _this.extensions, _this.appBar, _this.avatar, + _this.bottomAppBar, _this.avatarGroup, _this.avatarStack, _this.badgeCount, @@ -386,6 +397,7 @@ mixin _$StreamComponentBuilders { _this.fileTypeIcon, _this.listTile, _this.loadingSpinner, + _this.mediaViewer, _this.messageAnnotation, _this.messageBubble, _this.messageComposerAttachment, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 525442e..b273b1f 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -5,6 +5,7 @@ export 'theme/components/stream_audio_waveform_theme.dart'; export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; export 'theme/components/stream_badge_notification_theme.dart'; +export 'theme/components/stream_bottom_app_bar_theme.dart'; export 'theme/components/stream_button_theme.dart'; export 'theme/components/stream_checkbox_theme.dart'; export 'theme/components/stream_command_chip_theme.dart'; @@ -14,6 +15,7 @@ export 'theme/components/stream_emoji_button_theme.dart'; export 'theme/components/stream_emoji_chip_theme.dart'; export 'theme/components/stream_jump_to_unread_button_theme.dart'; export 'theme/components/stream_list_tile_theme.dart'; +export 'theme/components/stream_media_viewer_theme.dart'; export 'theme/components/stream_message_annotation_theme.dart'; export 'theme/components/stream_message_attachment_theme.dart'; export 'theme/components/stream_message_bubble_theme.dart'; 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 new file mode 100644 index 0000000..ce125fe --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.dart @@ -0,0 +1,188 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; +import 'stream_button_theme.dart'; + +part 'stream_bottom_app_bar_theme.g.theme.dart'; + +/// Applies a bottom app bar theme to descendant [StreamBottomAppBar] widgets. +/// +/// Wrap a subtree with [StreamBottomAppBarTheme] to override bottom app bar +/// styling. Access the merged theme using +/// [BuildContext.streamBottomAppBarTheme]. +/// +/// {@tool snippet} +/// +/// Override bottom app bar background for a specific subtree: +/// +/// ```dart +/// StreamBottomAppBarTheme( +/// data: StreamBottomAppBarThemeData( +/// style: StreamBottomAppBarStyle(backgroundColor: Color(0xFFF6F7F9)), +/// ), +/// child: Scaffold( +/// bottomNavigationBar: StreamBottomAppBar(title: Text('1 of 9')), +/// body: ..., +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomAppBarThemeData], which describes the bottom app bar theme. +/// * [StreamBottomAppBarStyle], the reusable visual style embedded by the +/// theme. +/// * [StreamBottomAppBar], the widget affected by this theme. +class StreamBottomAppBarTheme extends InheritedTheme { + /// Creates a bottom app bar theme that controls descendant bottom app bars. + const StreamBottomAppBarTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The bottom app bar theme data for descendant widgets. + final StreamBottomAppBarThemeData data; + + /// Returns the [StreamBottomAppBarThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamBottomAppBarTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamBottomAppBarThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).bottomAppBarTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamBottomAppBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamBottomAppBarTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamBottomAppBar] widgets. +/// +/// Wraps a [StreamBottomAppBarStyle] so it can be served by +/// [StreamBottomAppBarTheme] and slotted into [StreamTheme] alongside other +/// component theme data classes. +/// +/// {@tool snippet} +/// +/// Customize bottom app bar appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// bottomAppBarTheme: StreamBottomAppBarThemeData( +/// style: StreamBottomAppBarStyle( +/// padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomAppBarStyle], the reusable visual style embedded here. +/// * [StreamBottomAppBarTheme], for overriding the theme in a widget subtree. +/// * [StreamBottomAppBar], the widget that uses this theme data. +@themeGen +@immutable +class StreamBottomAppBarThemeData with _$StreamBottomAppBarThemeData { + /// Creates bottom app bar theme data. + const StreamBottomAppBarThemeData({this.style}); + + /// Visual styling for the bottom app bar. + final StreamBottomAppBarStyle? style; + + /// Linearly interpolate between two [StreamBottomAppBarThemeData] objects. + static StreamBottomAppBarThemeData? lerp( + StreamBottomAppBarThemeData? a, + StreamBottomAppBarThemeData? b, + double t, + ) => _$StreamBottomAppBarThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a [StreamBottomAppBar]. +/// +/// Defines the appearance of the bottom app bar — background colour, +/// padding, inter-slot spacing, title and subtitle text styles, and +/// per-slot button style propagation. +/// +/// Exposed separately from [StreamBottomAppBarThemeData] so other theme data +/// classes can embed a bottom-app-bar style via a typed field. +/// +/// {@tool snippet} +/// +/// Compose a style and hand it to a bottom app bar theme: +/// +/// ```dart +/// StreamBottomAppBarStyle( +/// backgroundColor: Color(0xFFFFFFFF), +/// padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), +/// spacing: 8, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBottomAppBarThemeData], which wraps this style for theming. +/// * [StreamBottomAppBar], which uses this styling. +@themeGen +@immutable +class StreamBottomAppBarStyle with _$StreamBottomAppBarStyle { + /// Creates a bottom app bar style with optional property overrides. + const StreamBottomAppBarStyle({ + this.backgroundColor, + this.padding, + this.spacing, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingStyle, + this.trailingStyle, + }); + + /// The background colour of the bottom app bar. + final Color? backgroundColor; + + /// The padding around the bar's content row. + final EdgeInsetsGeometry? padding; + + /// The horizontal space between the leading, heading, and trailing slots. + final double? spacing; + + /// The text style for [StreamBottomAppBar.title]. + final TextStyle? titleTextStyle; + + /// The text style for [StreamBottomAppBar.subtitle]. + final TextStyle? subtitleTextStyle; + + /// The button style for any [StreamButton] rendered in + /// [StreamBottomAppBar.leading]. + /// + /// Applied via a scoped [StreamButtonTheme] so any [StreamButton] dropped + /// into the slot picks it up regardless of the button's configured `style` + /// or `type`. Per-instance `themeStyle` overrides still win via merge. + final StreamButtonThemeStyle? leadingStyle; + + /// The button style for any [StreamButton] rendered in + /// [StreamBottomAppBar.trailing]. + /// + /// Applied via a scoped [StreamButtonTheme] so any [StreamButton] dropped + /// into the slot picks it up regardless of the button's configured `style` + /// or `type`. Per-instance `themeStyle` overrides still win via merge. + final StreamButtonThemeStyle? trailingStyle; + + /// Linearly interpolate between two [StreamBottomAppBarStyle] objects. + static StreamBottomAppBarStyle? lerp( + StreamBottomAppBarStyle? a, + StreamBottomAppBarStyle? b, + double t, + ) => _$StreamBottomAppBarStyle.lerp(a, b, t); +} 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 new file mode 100644 index 0000000..cd9f90b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_bottom_app_bar_theme.g.theme.dart @@ -0,0 +1,212 @@ +// 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_app_bar_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamBottomAppBarThemeData { + bool get canMerge => true; + + static StreamBottomAppBarThemeData? lerp( + StreamBottomAppBarThemeData? a, + StreamBottomAppBarThemeData? 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 StreamBottomAppBarThemeData( + style: StreamBottomAppBarStyle.lerp(a.style, b.style, t), + ); + } + + StreamBottomAppBarThemeData copyWith({StreamBottomAppBarStyle? style}) { + final _this = (this as StreamBottomAppBarThemeData); + + return StreamBottomAppBarThemeData(style: style ?? _this.style); + } + + StreamBottomAppBarThemeData merge(StreamBottomAppBarThemeData? other) { + final _this = (this as StreamBottomAppBarThemeData); + + 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 StreamBottomAppBarThemeData); + final _other = (other as StreamBottomAppBarThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamBottomAppBarThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamBottomAppBarStyle { + bool get canMerge => true; + + static StreamBottomAppBarStyle? lerp( + StreamBottomAppBarStyle? a, + StreamBottomAppBarStyle? 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 StreamBottomAppBarStyle( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + leadingStyle: StreamButtonThemeStyle.lerp( + a.leadingStyle, + b.leadingStyle, + t, + ), + trailingStyle: StreamButtonThemeStyle.lerp( + a.trailingStyle, + b.trailingStyle, + t, + ), + ); + } + + StreamBottomAppBarStyle copyWith({ + Color? backgroundColor, + EdgeInsetsGeometry? padding, + double? spacing, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + StreamButtonThemeStyle? leadingStyle, + StreamButtonThemeStyle? trailingStyle, + }) { + final _this = (this as StreamBottomAppBarStyle); + + return StreamBottomAppBarStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + leadingStyle: leadingStyle ?? _this.leadingStyle, + trailingStyle: trailingStyle ?? _this.trailingStyle, + ); + } + + StreamBottomAppBarStyle merge(StreamBottomAppBarStyle? other) { + final _this = (this as StreamBottomAppBarStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + padding: other.padding, + spacing: other.spacing, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + leadingStyle: + _this.leadingStyle?.merge(other.leadingStyle) ?? other.leadingStyle, + trailingStyle: + _this.trailingStyle?.merge(other.trailingStyle) ?? + other.trailingStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamBottomAppBarStyle); + final _other = (other as StreamBottomAppBarStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.padding == _this.padding && + _other.spacing == _this.spacing && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.leadingStyle == _this.leadingStyle && + _other.trailingStyle == _this.trailingStyle; + } + + @override + int get hashCode { + final _this = (this as StreamBottomAppBarStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.padding, + _this.spacing, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.leadingStyle, + _this.trailingStyle, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_media_viewer_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_media_viewer_theme.dart new file mode 100644 index 0000000..0c2fb3e --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_media_viewer_theme.dart @@ -0,0 +1,98 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; +import 'stream_app_bar_theme.dart'; +import 'stream_bottom_app_bar_theme.dart'; + +part 'stream_media_viewer_theme.g.theme.dart'; + +/// Applies a media viewer theme to descendant [StreamMediaViewer]s. +/// +/// {@tool snippet} +/// +/// Tint the chrome for a specific subtree: +/// +/// ```dart +/// StreamMediaViewerTheme( +/// data: StreamMediaViewerThemeData( +/// appBarStyle: StreamAppBarStyle(backgroundColor: Colors.transparent), +/// ), +/// child: child, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaViewerThemeData], which describes the theme. +/// * [StreamMediaViewer], the widget affected by this theme. +class StreamMediaViewerTheme extends InheritedTheme { + /// Creates a media viewer theme that controls descendant media viewers. + const StreamMediaViewerTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The media viewer theme data for descendant widgets. + final StreamMediaViewerThemeData data; + + /// Returns the [StreamMediaViewerThemeData] merged from local and global + /// themes. Local values take precedence. + static StreamMediaViewerThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).mediaViewerTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMediaViewerTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMediaViewerTheme oldWidget) => data != oldWidget.data; +} + +/// Visual styling properties for [StreamMediaViewer]. +/// +/// Unset fields fall back to the viewer's built-in defaults. +/// +/// See also: +/// +/// * [StreamMediaViewerTheme], which serves this data to descendants. +/// * [StreamMediaViewer], which uses this theme. +@themeGen +@immutable +class StreamMediaViewerThemeData with _$StreamMediaViewerThemeData { + /// Creates media viewer theme data. + const StreamMediaViewerThemeData({ + this.backgroundColor, + this.immersiveBackgroundColor, + this.chromeAnimationDuration, + this.appBarStyle, + this.bottomAppBarStyle, + }); + + /// Background colour behind media when chrome is visible. + final Color? backgroundColor; + + /// Background colour behind media when chrome is hidden. + final Color? immersiveBackgroundColor; + + /// Duration of the chrome show/hide animation. + final Duration? chromeAnimationDuration; + + /// Style scoped to the [StreamAppBar] rendered as the viewer's header. + final StreamAppBarStyle? appBarStyle; + + /// Style scoped to the [StreamBottomAppBar] rendered as the viewer's footer. + final StreamBottomAppBarStyle? bottomAppBarStyle; + + /// Linearly interpolate between two [StreamMediaViewerThemeData] objects. + static StreamMediaViewerThemeData? lerp( + StreamMediaViewerThemeData? a, + StreamMediaViewerThemeData? b, + double t, + ) => _$StreamMediaViewerThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_media_viewer_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_media_viewer_theme.g.theme.dart new file mode 100644 index 0000000..3c10be4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_media_viewer_theme.g.theme.dart @@ -0,0 +1,129 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_media_viewer_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMediaViewerThemeData { + bool get canMerge => true; + + static StreamMediaViewerThemeData? lerp( + StreamMediaViewerThemeData? a, + StreamMediaViewerThemeData? 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 StreamMediaViewerThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + immersiveBackgroundColor: Color.lerp( + a.immersiveBackgroundColor, + b.immersiveBackgroundColor, + t, + ), + chromeAnimationDuration: lerpDuration$( + a.chromeAnimationDuration, + b.chromeAnimationDuration, + t, + ), + appBarStyle: StreamAppBarStyle.lerp(a.appBarStyle, b.appBarStyle, t), + bottomAppBarStyle: StreamBottomAppBarStyle.lerp( + a.bottomAppBarStyle, + b.bottomAppBarStyle, + t, + ), + ); + } + + StreamMediaViewerThemeData copyWith({ + Color? backgroundColor, + Color? immersiveBackgroundColor, + Duration? chromeAnimationDuration, + StreamAppBarStyle? appBarStyle, + StreamBottomAppBarStyle? bottomAppBarStyle, + }) { + final _this = (this as StreamMediaViewerThemeData); + + return StreamMediaViewerThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + immersiveBackgroundColor: + immersiveBackgroundColor ?? _this.immersiveBackgroundColor, + chromeAnimationDuration: + chromeAnimationDuration ?? _this.chromeAnimationDuration, + appBarStyle: appBarStyle ?? _this.appBarStyle, + bottomAppBarStyle: bottomAppBarStyle ?? _this.bottomAppBarStyle, + ); + } + + StreamMediaViewerThemeData merge(StreamMediaViewerThemeData? other) { + final _this = (this as StreamMediaViewerThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + immersiveBackgroundColor: other.immersiveBackgroundColor, + chromeAnimationDuration: other.chromeAnimationDuration, + appBarStyle: + _this.appBarStyle?.merge(other.appBarStyle) ?? other.appBarStyle, + bottomAppBarStyle: + _this.bottomAppBarStyle?.merge(other.bottomAppBarStyle) ?? + other.bottomAppBarStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMediaViewerThemeData); + final _other = (other as StreamMediaViewerThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.immersiveBackgroundColor == _this.immersiveBackgroundColor && + _other.chromeAnimationDuration == _this.chromeAnimationDuration && + _other.appBarStyle == _this.appBarStyle && + _other.bottomAppBarStyle == _this.bottomAppBarStyle; + } + + @override + int get hashCode { + final _this = (this as StreamMediaViewerThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.immersiveBackgroundColor, + _this.chromeAnimationDuration, + _this.appBarStyle, + _this.bottomAppBarStyle, + ); + } +} 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 db934f4..663055f 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -9,6 +9,7 @@ import 'components/stream_audio_waveform_theme.dart'; 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_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; import 'components/stream_command_chip_theme.dart'; @@ -18,6 +19,7 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_media_viewer_theme.dart'; import 'components/stream_message_composer_attachment_theme.dart'; import 'components/stream_message_composer_edit_message_attachment_theme.dart'; import 'components/stream_message_composer_file_attachment_theme.dart'; @@ -116,6 +118,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamBadgeNotificationThemeData? badgeNotificationTheme, + StreamBottomAppBarThemeData? bottomAppBarTheme, StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamCommandChipThemeData? commandChipTheme, @@ -125,6 +128,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiChipThemeData? emojiChipTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, + StreamMediaViewerThemeData? mediaViewerTheme, StreamMessageComposerAttachmentThemeData? messageComposerAttachmentTheme, StreamMessageComposerEditMessageAttachmentThemeData? messageComposerEditMessageAttachmentTheme, StreamMessageComposerFileAttachmentThemeData? messageComposerFileAttachmentTheme, @@ -165,6 +169,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); badgeNotificationTheme ??= const StreamBadgeNotificationThemeData(); + bottomAppBarTheme ??= const StreamBottomAppBarThemeData(); buttonTheme ??= const StreamButtonThemeData(); checkboxTheme ??= const StreamCheckboxThemeData(); commandChipTheme ??= const StreamCommandChipThemeData(); @@ -174,6 +179,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme ??= const StreamEmojiChipThemeData(); jumpToUnreadButtonTheme ??= const StreamJumpToUnreadButtonThemeData(); listTileTheme ??= const StreamListTileThemeData(); + mediaViewerTheme ??= const StreamMediaViewerThemeData(); messageComposerAttachmentTheme ??= const StreamMessageComposerAttachmentThemeData(); messageComposerEditMessageAttachmentTheme ??= const StreamMessageComposerEditMessageAttachmentThemeData(); messageComposerFileAttachmentTheme ??= const StreamMessageComposerFileAttachmentThemeData(); @@ -208,6 +214,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, badgeNotificationTheme: badgeNotificationTheme, + bottomAppBarTheme: bottomAppBarTheme, buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, commandChipTheme: commandChipTheme, @@ -217,6 +224,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme: emojiChipTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, + mediaViewerTheme: mediaViewerTheme, messageComposerAttachmentTheme: messageComposerAttachmentTheme, messageComposerEditMessageAttachmentTheme: messageComposerEditMessageAttachmentTheme, messageComposerFileAttachmentTheme: messageComposerFileAttachmentTheme, @@ -265,6 +273,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.avatarTheme, required this.badgeCountTheme, required this.badgeNotificationTheme, + required this.bottomAppBarTheme, required this.buttonTheme, required this.checkboxTheme, required this.commandChipTheme, @@ -274,6 +283,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiChipTheme, required this.jumpToUnreadButtonTheme, required this.listTileTheme, + required this.mediaViewerTheme, required this.messageComposerAttachmentTheme, required this.messageComposerEditMessageAttachmentTheme, required this.messageComposerFileAttachmentTheme, @@ -368,6 +378,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The badge notification theme for this theme. final StreamBadgeNotificationThemeData badgeNotificationTheme; + /// The bottom app bar theme for this theme. + final StreamBottomAppBarThemeData bottomAppBarTheme; + /// The button theme for this theme. final StreamButtonThemeData buttonTheme; @@ -395,6 +408,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; + /// The media viewer theme for this theme. + final StreamMediaViewerThemeData mediaViewerTheme; + /// The composer attachment container theme for this theme. final StreamMessageComposerAttachmentThemeData messageComposerAttachmentTheme; @@ -487,6 +503,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, badgeNotificationTheme: badgeNotificationTheme, + bottomAppBarTheme: bottomAppBarTheme, buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, commandChipTheme: commandChipTheme, @@ -496,6 +513,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme: emojiChipTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, + mediaViewerTheme: mediaViewerTheme, messageComposerAttachmentTheme: messageComposerAttachmentTheme, messageComposerEditMessageAttachmentTheme: messageComposerEditMessageAttachmentTheme, messageComposerFileAttachmentTheme: messageComposerFileAttachmentTheme, 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 a7b8926..dce2813 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 @@ -25,6 +25,7 @@ mixin _$StreamTheme on ThemeExtension { StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamBadgeNotificationThemeData? badgeNotificationTheme, + StreamBottomAppBarThemeData? bottomAppBarTheme, StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamCommandChipThemeData? commandChipTheme, @@ -34,6 +35,7 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiChipThemeData? emojiChipTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, + StreamMediaViewerThemeData? mediaViewerTheme, StreamMessageComposerAttachmentThemeData? messageComposerAttachmentTheme, StreamMessageComposerEditMessageAttachmentThemeData? messageComposerEditMessageAttachmentTheme, @@ -77,6 +79,7 @@ mixin _$StreamTheme on ThemeExtension { badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, badgeNotificationTheme: badgeNotificationTheme ?? _this.badgeNotificationTheme, + bottomAppBarTheme: bottomAppBarTheme ?? _this.bottomAppBarTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, checkboxTheme: checkboxTheme ?? _this.checkboxTheme, commandChipTheme: commandChipTheme ?? _this.commandChipTheme, @@ -88,6 +91,7 @@ mixin _$StreamTheme on ThemeExtension { jumpToUnreadButtonTheme: jumpToUnreadButtonTheme ?? _this.jumpToUnreadButtonTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, + mediaViewerTheme: mediaViewerTheme ?? _this.mediaViewerTheme, messageComposerAttachmentTheme: messageComposerAttachmentTheme ?? _this.messageComposerAttachmentTheme, @@ -171,6 +175,11 @@ mixin _$StreamTheme on ThemeExtension { other.badgeNotificationTheme, t, )!, + bottomAppBarTheme: StreamBottomAppBarThemeData.lerp( + _this.bottomAppBarTheme, + other.bottomAppBarTheme, + t, + )!, buttonTheme: StreamButtonThemeData.lerp( _this.buttonTheme, other.buttonTheme, @@ -216,6 +225,11 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, + mediaViewerTheme: StreamMediaViewerThemeData.lerp( + _this.mediaViewerTheme, + other.mediaViewerTheme, + t, + )!, messageComposerAttachmentTheme: StreamMessageComposerAttachmentThemeData.lerp( _this.messageComposerAttachmentTheme, @@ -347,6 +361,7 @@ mixin _$StreamTheme on ThemeExtension { _other.avatarTheme == _this.avatarTheme && _other.badgeCountTheme == _this.badgeCountTheme && _other.badgeNotificationTheme == _this.badgeNotificationTheme && + _other.bottomAppBarTheme == _this.bottomAppBarTheme && _other.buttonTheme == _this.buttonTheme && _other.checkboxTheme == _this.checkboxTheme && _other.commandChipTheme == _this.commandChipTheme && @@ -356,6 +371,7 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiChipTheme == _this.emojiChipTheme && _other.jumpToUnreadButtonTheme == _this.jumpToUnreadButtonTheme && _other.listTileTheme == _this.listTileTheme && + _other.mediaViewerTheme == _this.mediaViewerTheme && _other.messageComposerAttachmentTheme == _this.messageComposerAttachmentTheme && _other.messageComposerEditMessageAttachmentTheme == @@ -403,6 +419,7 @@ mixin _$StreamTheme on ThemeExtension { _this.avatarTheme, _this.badgeCountTheme, _this.badgeNotificationTheme, + _this.bottomAppBarTheme, _this.buttonTheme, _this.checkboxTheme, _this.commandChipTheme, @@ -412,6 +429,7 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiChipTheme, _this.jumpToUnreadButtonTheme, _this.listTileTheme, + _this.mediaViewerTheme, _this.messageComposerAttachmentTheme, _this.messageComposerEditMessageAttachmentTheme, _this.messageComposerFileAttachmentTheme, 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 58808d5..8beaa79 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 @@ -5,6 +5,7 @@ import 'components/stream_audio_waveform_theme.dart'; 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_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; import 'components/stream_command_chip_theme.dart'; @@ -14,6 +15,7 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_media_viewer_theme.dart'; import 'components/stream_message_composer_attachment_theme.dart'; import 'components/stream_message_composer_edit_message_attachment_theme.dart'; import 'components/stream_message_composer_file_attachment_theme.dart'; @@ -102,6 +104,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamBadgeNotificationThemeData] from the nearest ancestor. StreamBadgeNotificationThemeData get streamBadgeNotificationTheme => StreamBadgeNotificationTheme.of(this); + /// Returns the [StreamBottomAppBarThemeData] from the nearest ancestor. + StreamBottomAppBarThemeData get streamBottomAppBarTheme => StreamBottomAppBarTheme.of(this); + /// Returns the [StreamButtonThemeData] from the nearest ancestor. StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); @@ -129,6 +134,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMediaViewerThemeData] from the nearest ancestor. + StreamMediaViewerThemeData get streamMediaViewerTheme => StreamMediaViewerTheme.of(this); + /// Returns the [StreamMessageComposerAttachmentThemeData] from the nearest ancestor. StreamMessageComposerAttachmentThemeData get streamMessageComposerAttachmentTheme => StreamMessageComposerAttachmentTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart similarity index 97% rename from packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart rename to packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart index bc4fb3e..64863a5 100644 --- a/packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_app_bar_test.dart @@ -109,11 +109,11 @@ void main() { ); // Props win for padding (the bar passes its resolved padding through - // to the [StreamHeaderToolbar]'s `padding` property). - final toolbar = tester.widget( + // to the [StreamToolbar]'s `padding` property). + final toolbar = tester.widget( find.descendant( of: find.byType(StreamAppBar), - matching: find.byType(StreamHeaderToolbar), + matching: find.byType(StreamToolbar), ), ); expect(toolbar.padding, equals(propsPadding)); diff --git a/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart new file mode 100644 index 0000000..9592090 --- /dev/null +++ b/packages/stream_core_flutter/test/components/toolbar/stream_bottom_app_bar_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +Widget _withStreamTheme(Widget child) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme()]), + home: child, + ); +} + +void main() { + group('StreamBottomAppBar slots', () { + testWidgets('renders title only — no leading or trailing', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold(bottomNavigationBar: StreamBottomAppBar(title: const Text('1 of 9'))), + ), + ); + + expect(find.text('1 of 9'), findsOneWidget); + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('renders title, subtitle, leading and trailing', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + bottomNavigationBar: StreamBottomAppBar( + leading: StreamButton.icon( + key: const ValueKey('leading'), + icon: const Icon(Icons.share), + onPressed: () {}, + ), + title: const Text('1 of 9'), + subtitle: const Text('Tap to share'), + trailing: StreamButton.icon( + key: const ValueKey('trailing'), + icon: const Icon(Icons.grid_view), + onPressed: () {}, + ), + ), + ), + ), + ); + + expect(find.text('1 of 9'), findsOneWidget); + expect(find.text('Tap to share'), findsOneWidget); + expect(find.byKey(const ValueKey('leading')), findsOneWidget); + expect(find.byKey(const ValueKey('trailing')), findsOneWidget); + }); + + testWidgets('preferredSize is kStreamToolbarHeight', (tester) async { + final bar = StreamBottomAppBar(title: const Text('Title')); + expect(bar.preferredSize, equals(const Size.fromHeight(kStreamToolbarHeight))); + }); + }); + + group('StreamBottomAppBar primary / SafeArea', () { + testWidgets('primary: true wraps in SafeArea(top: false)', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold(bottomNavigationBar: StreamBottomAppBar(title: const Text('Title'))), + ), + ); + + final safeArea = tester.widget( + find.descendant( + of: find.byType(StreamBottomAppBar), + matching: find.byType(SafeArea), + ), + ); + + // top: false so the bar can sit flush at the top of its slot when + // it isn't the topmost chrome; bottom: true (default) consumes the + // system bottom inset (home indicator). + expect(safeArea.top, isFalse); + expect(safeArea.bottom, isTrue); + }); + + testWidgets('primary: false skips the SafeArea wrap', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Scaffold( + bottomNavigationBar: StreamBottomAppBar( + primary: false, + title: const Text('Title'), + ), + ), + ), + ); + + expect( + find.descendant( + of: find.byType(StreamBottomAppBar), + matching: find.byType(SafeArea), + ), + findsNothing, + ); + }); + }); + + group('StreamBottomAppBar style precedence', () { + testWidgets( + 'props.style > theme.style > token defaults (three-level merge)', + (tester) async { + const propsPadding = EdgeInsets.all(7); + const themeTitleStyle = TextStyle(fontSize: 18, color: Color(0xFF112233)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme()]), + home: StreamBottomAppBarTheme( + data: const StreamBottomAppBarThemeData( + style: StreamBottomAppBarStyle(titleTextStyle: themeTitleStyle), + ), + child: Scaffold( + bottomNavigationBar: StreamBottomAppBar( + title: const Text('Title'), + subtitle: const Text('Subtitle'), + style: const StreamBottomAppBarStyle(padding: propsPadding), + ), + ), + ), + ), + ); + + // Props win for padding (the bar passes its resolved padding through + // to the [StreamToolbar]'s `padding` property). + final toolbar = tester.widget( + find.descendant( + of: find.byType(StreamBottomAppBar), + matching: find.byType(StreamToolbar), + ), + ); + expect(toolbar.padding, equals(propsPadding)); + + // Theme wins for titleTextStyle (props didn't set it). + final titleStyle = tester + .widget( + find + .ancestor( + of: find.text('Title'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ) + .style; + expect(titleStyle.fontSize, equals(themeTitleStyle.fontSize)); + expect(titleStyle.color, equals(themeTitleStyle.color)); + + // Subtitle falls through to defaults (neither props nor theme set it). + final subtitleStyle = tester + .widget( + find + .ancestor( + of: find.text('Subtitle'), + matching: find.byType(DefaultTextStyle), + ) + .first, + ) + .style; + expect(subtitleStyle.fontSize, isNotNull); + expect(subtitleStyle.fontSize, greaterThan(0)); + }, + ); + }); +} diff --git a/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart b/packages/stream_core_flutter/test/components/toolbar/stream_sheet_header_test.dart similarity index 99% rename from packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart rename to packages/stream_core_flutter/test/components/toolbar/stream_sheet_header_test.dart index a9ba97e..5e2ab36 100644 --- a/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart +++ b/packages/stream_core_flutter/test/components/toolbar/stream_sheet_header_test.dart @@ -391,11 +391,11 @@ void main() { ); // Props win for padding (the header passes its resolved padding - // through to the [StreamHeaderToolbar]'s `padding` property). - final toolbar = tester.widget( + // through to the [StreamToolbar]'s `padding` property). + final toolbar = tester.widget( find.descendant( of: find.byType(StreamSheetHeader), - matching: find.byType(StreamHeaderToolbar), + matching: find.byType(StreamToolbar), ), ); expect(toolbar.padding, equals(propsPadding));