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 8d13c1b0..a9a5b671 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,6 +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/message/stream_message_annotation.dart' @@ -832,6 +834,21 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookFolder( name: 'Header', 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', useCases: [ diff --git a/apps/design_system_gallery/lib/components/header/stream_app_bar.dart b/apps/design_system_gallery/lib/components/header/stream_app_bar.dart new file mode 100644 index 00000000..38e6b9ce --- /dev/null +++ b/apps/design_system_gallery/lib/components/header/stream_app_bar.dart @@ -0,0 +1,341 @@ +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: StreamAppBar, + path: '[Components]/Header', +) +Widget buildStreamAppBarPlayground(BuildContext context) { + final title = context.knobs.stringOrNull( + label: 'Title', + initialValue: 'Details', + description: 'The primary header text. Clear to omit the title.', + ); + + 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 a back-style icon button before the title. ' + 'When off, auto-implied leading only appears if the bar is ' + 'inside a poppable route (see Showcase).', + ); + + final showTrailing = context.knobs.boolean( + label: 'Show trailing', + initialValue: true, + description: 'Renders a primary-action button after the title.', + ); + + 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.', + ); + + return Align( + alignment: Alignment.topCenter, + child: StreamAppBar( + style: StreamAppBarStyle( + padding: EdgeInsets.all(padding), + spacing: spacing, + ), + leading: showLeading + ? StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ) + : null, + title: (title != null && title.isNotEmpty) ? Text(title) : null, + subtitle: (subtitle != null && subtitle.isNotEmpty) ? Text(subtitle) : null, + trailing: showTrailing + ? StreamButton.icon( + icon: Icon(context.streamIcons.plus), + onPressed: () {}, + ) + : null, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamAppBar, + path: '[Components]/Header', +) +Widget buildStreamAppBarShowcase(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: [ + _AppBarExample( + label: 'Title only', + bar: StreamAppBar(title: const Text('Details')), + ), + SizedBox(height: spacing.md), + _AppBarExample( + label: 'Title and subtitle', + bar: StreamAppBar( + title: const Text('Details'), + subtitle: const Text('Additional information'), + ), + ), + SizedBox(height: spacing.md), + _AppBarExample( + label: 'Leading only — trailing reserves a spacer', + bar: StreamAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('Details'), + ), + ), + SizedBox(height: spacing.md), + _AppBarExample( + label: 'Trailing only — leading reserves a spacer', + bar: StreamAppBar( + title: const Text('Details'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + onPressed: () {}, + ), + ), + ), + SizedBox(height: spacing.md), + _AppBarExample( + label: 'Full layout with subtitle', + bar: StreamAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('Group chat'), + subtitle: const Text('5 members, 3 online'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + onPressed: () {}, + ), + ), + ), + SizedBox(height: spacing.md), + _AppBarExample( + label: 'Long title truncates gracefully', + bar: StreamAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text( + 'A rather long title that should ellipsize gracefully', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + onPressed: () {}, + ), + ), + ), + 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 + // space around the middle so the title stays geometrically + // centred in the bar's full width. + _AppBarExample( + label: 'Asymmetric leading / trailing — title stays centred', + bar: StreamAppBar( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + onPressed: () {}, + ), + title: const Text('Group Info'), + trailing: StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + size: StreamButtonSize.small, + onPressed: () {}, + child: const Text('Edit'), + ), + ), + ), + SizedBox(height: spacing.md), + _AppBarExample( + label: 'Style leadingStyle/trailingStyle propagates to plain StreamButtons', + bar: StreamAppBar( + style: StreamAppBarStyle( + leadingStyle: StreamButtonThemeStyle.from( + backgroundColor: colorScheme.backgroundSurfaceSubtle, + foregroundColor: colorScheme.textPrimary, + ), + trailingStyle: StreamButtonThemeStyle.from( + backgroundColor: colorScheme.accentError, + foregroundColor: colorScheme.textOnAccent, + ), + ), + leading: StreamButton.icon( + icon: Icon(context.streamIcons.chevronLeft), + onPressed: () {}, + ), + title: const Text('Discard changes?'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.delete), + onPressed: () {}, + ), + ), + ), + SizedBox(height: spacing.md), + const _AutoImplyLeadingDemo(), + ], + ), + ), + ); +} + +// Demonstrates the auto-implied leading button. Each launcher pushes a route +// whose Scaffold uses a StreamAppBar with no explicit `leading`. On a regular +// pushed page the icon adapts to the host platform — chevron on iOS / macOS, +// arrow-left on Android / web / desktop. On a fullscreen dialog the icon is +// always a cross. Pressing the auto-inserted button pops the route. +class _AutoImplyLeadingDemo extends StatelessWidget { + const _AutoImplyLeadingDemo(); + + @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( + 'Auto-implied leading — tap to see the pushed app bar', + 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.sm), + child: Row( + spacing: spacing.sm, + children: [ + Expanded( + child: StreamButton( + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + onPressed: () => _push(context, fullscreenDialog: false), + child: const Text('Push page'), + ), + ), + Expanded( + child: StreamButton( + onPressed: () => _push(context, fullscreenDialog: true), + child: const Text('Push fullscreen dialog'), + ), + ), + ], + ), + ), + ], + ); + } + + void _push(BuildContext context, {required bool fullscreenDialog}) { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: fullscreenDialog, + builder: (_) => Scaffold( + appBar: StreamAppBar( + title: Text(fullscreenDialog ? 'Fullscreen dialog' : 'Pushed page'), + ), + body: const Center(child: Text('Pop via the auto-implied leading button.')), + ), + ), + ); + } +} + +class _AppBarExample extends StatelessWidget { + const _AppBarExample({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/header/stream_sheet_header.dart index e963821b..8adfe616 100644 --- a/apps/design_system_gallery/lib/components/header/stream_sheet_header.dart +++ b/apps/design_system_gallery/lib/components/header/stream_sheet_header.dart @@ -177,6 +177,29 @@ 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 + // space around the middle so the title stays geometrically + // centred in the bar's full width. + _HeaderExample( + label: 'Asymmetric leading / trailing — title stays centred', + header: StreamSheetHeader( + leading: StreamButton.icon( + icon: Icon(context.streamIcons.xmark), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + onPressed: () {}, + ), + title: const Text('Edit profile'), + trailing: StreamButton( + size: StreamButtonSize.small, + onPressed: () {}, + child: const Text('Save'), + ), + ), + ), + SizedBox(height: spacing.md), _HeaderExample( label: 'Style leadingStyle/trailingStyle propagates to plain StreamButtons', header: StreamSheetHeader( diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 315fb1e0..c4834887 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -7,7 +7,8 @@ - Added `StreamFileTypeIconSize.md` and `StreamFileTypeIconSize.sm` variants. - 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 auto-implied dismissal based on the enclosing route. +- 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 `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. @@ -36,6 +37,7 @@ - Renamed `StreamFileTypeIconSize` variants: `s48` → `xl`, `s40` → `lg`. - Removed `StreamMessageAnnotation.rich` and `spanTextStyle`/`spanTextColor`; use the new `trailing` slot instead. - Aligned `StreamButton` API with Flutter's built-in buttons: renamed `label` (`String?`) to required `child` (`Widget`), changed `icon`/`iconLeft`/`iconRight` from `IconData` to `Widget`, and renamed `onTap` to `onPressed`. `StreamButtonProps` mirrors the same renames. +- Redesigned `StreamAppBar` with a slots-based API (`leading`/`title`/`subtitle`/`trailing`) and platform-aware auto-implied leading; replaces the previous Material `AppBar` wrapper. Adds `StreamAppBarStyle`, `StreamAppBarTheme`, and `StreamAppBarThemeData`. - `placeholder` on `StreamCoreMessageComposer`, `StreamMessageComposerInput`, and `StreamMessageComposerInputField` is now an optional `String?` (was `String` defaulting to `''`, and `required` on `StreamMessageComposerInputField`). ## 0.2.0 diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 3696cb64..6edf46a2 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -14,7 +14,6 @@ export 'components/badge/stream_retry_badge.dart' hide DefaultStreamRetryBadge; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButton; export 'components/buttons/stream_jump_to_unread_button.dart' hide DefaultStreamJumpToUnreadButton; -export 'components/common/stream_app_bar.dart' hide DefaultStreamAppBar; export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; export 'components/common/stream_flex.dart'; export 'components/common/stream_intrinsic_flex.dart'; @@ -38,6 +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/message/stream_message_annotation.dart' hide DefaultStreamMessageAnnotation; diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_app_bar.dart b/packages/stream_core_flutter/lib/src/components/common/stream_app_bar.dart deleted file mode 100644 index 969fc5e2..00000000 --- a/packages/stream_core_flutter/lib/src/components/common/stream_app_bar.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../factory/stream_component_factory.dart'; -import '../../theme/semantics/stream_text_theme.dart'; -import '../../theme/stream_theme_extensions.dart'; - -/// A styled [AppBar] with Stream defaults applied. -/// -/// [StreamAppBar] renders a standard Material [AppBar] with -/// defaults from the Stream design system applied. -/// -/// {@tool snippet} -/// -/// Display a simple app bar with a title: -/// -/// ```dart -/// StreamAppBar( -/// title: Text('Messages'), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [DefaultStreamAppBar], the default visual implementation. -class StreamAppBar extends StatelessWidget implements PreferredSizeWidget { - /// Creates a Stream-styled app bar. - StreamAppBar({ - super.key, - Widget? leading, - double? leadingWidth, - bool automaticallyImplyLeading = true, - Widget? title, - double? titleSpacing, - TextStyle? titleTextStyle, - List? actions, - EdgeInsetsGeometry? actionsPadding, - bool? centerTitle, - PreferredSizeWidget? bottom, - double bottomOpacity = 1.0, - double elevation = 0, - double scrolledUnderElevation = 0, - Color? backgroundColor, - Color? surfaceTintColor, - ShapeBorder? shape, - }) : props = StreamAppBarProps( - leading: leading, - leadingWidth: leadingWidth, - automaticallyImplyLeading: automaticallyImplyLeading, - title: title, - titleSpacing: titleSpacing, - titleTextStyle: titleTextStyle, - actions: actions, - actionsPadding: actionsPadding, - centerTitle: centerTitle, - bottom: bottom, - bottomOpacity: bottomOpacity, - elevation: elevation, - scrolledUnderElevation: scrolledUnderElevation, - backgroundColor: backgroundColor, - surfaceTintColor: surfaceTintColor, - shape: shape, - ); - - /// The props controlling the appearance and behavior of this app bar. - final StreamAppBarProps props; - - @override - Size get preferredSize { - final bottomHeight = props.bottom?.preferredSize.height ?? 0; - return Size.fromHeight(kToolbarHeight + bottomHeight); - } - - @override - Widget build(BuildContext context) { - final builder = StreamComponentFactory.of(context).appBar; - if (builder != null) return builder(context, props); - return DefaultStreamAppBar(props: props); - } -} - -/// Properties for configuring a [StreamAppBar]. -/// -/// This class holds all the configuration options for an app bar, allowing -/// them to be passed through the [StreamComponentFactory]. -/// -/// See also: -/// -/// * [StreamAppBar], which uses these properties. -/// * [DefaultStreamAppBar], the default implementation. -class StreamAppBarProps { - /// Creates properties for an app bar. - const StreamAppBarProps({ - this.leading, - this.leadingWidth, - this.automaticallyImplyLeading = true, - this.title, - this.titleSpacing, - this.titleTextStyle, - this.actions, - this.actionsPadding, - this.centerTitle, - this.bottom, - this.bottomOpacity = 1.0, - this.elevation = 0, - this.scrolledUnderElevation = 0, - this.backgroundColor, - this.surfaceTintColor, - this.shape, - }); - - /// A widget to display before the toolbar's [title]. - final Widget? leading; - - /// Defines the width of [leading] widget. - final double? leadingWidth; - - /// Controls whether we should try to imply the leading widget if null. - final bool automaticallyImplyLeading; - - /// The primary widget displayed in the app bar. - final Widget? title; - - /// The spacing around [title] content on the horizontal axis. - final double? titleSpacing; - - /// The text style for the [title]. - /// - /// Defaults to [StreamTextTheme.headingSm]. - final TextStyle? titleTextStyle; - - /// {@macro flutter.material.appbar.actions} - final List? actions; - - /// Defines the padding for [actions]. - final EdgeInsetsGeometry? actionsPadding; - - /// Whether the title should be centered. - final bool? centerTitle; - - /// An app bar bottom widget, displayed below the [title]. - final PreferredSizeWidget? bottom; - - /// The opacity of the [bottom] widget. - final double bottomOpacity; - - /// The z-coordinate at which to place this app bar. - /// - /// Defaults to `0`. - final double elevation; - - /// The elevation when content is scrolled underneath the app bar. - /// - /// Defaults to `0`. - final double scrolledUnderElevation; - - /// The background color of the app bar. - final Color? backgroundColor; - - /// The surface tint color of the app bar. - final Color? surfaceTintColor; - - /// The shape of the app bar's [Material]. - /// - /// Defaults to a [LinearBorder] with a bottom edge using - /// `borderSubtle` color from the Stream color scheme. - final ShapeBorder? shape; -} - -/// Default implementation of [StreamAppBar]. -/// -/// Renders a Material [AppBar] with Stream design system defaults applied. -/// -/// See also: -/// -/// * [StreamAppBar], the public API widget. -/// * [StreamAppBarProps], which configures this widget. -class DefaultStreamAppBar extends StatelessWidget { - /// Creates a default Stream app bar. - const DefaultStreamAppBar({super.key, required this.props}); - - /// The props controlling the appearance and behavior of this app bar. - final StreamAppBarProps props; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final streamTextTheme = context.streamTextTheme; - final streamColorScheme = context.streamColorScheme; - - return AppBar( - automaticallyImplyLeading: props.automaticallyImplyLeading, - toolbarTextStyle: theme.textTheme.bodyMedium, - titleTextStyle: props.titleTextStyle ?? streamTextTheme.headingSm, - systemOverlayStyle: theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, - elevation: props.elevation, - scrolledUnderElevation: props.scrolledUnderElevation, - backgroundColor: props.backgroundColor, - surfaceTintColor: props.surfaceTintColor, - centerTitle: props.centerTitle, - leading: props.leading, - leadingWidth: props.leadingWidth, - titleSpacing: props.titleSpacing, - actions: props.actions, - actionsPadding: props.actionsPadding, - title: props.title, - bottom: props.bottom, - bottomOpacity: props.bottomOpacity, - shape: - props.shape ?? - LinearBorder( - side: BorderSide( - color: streamColorScheme.borderSubtle, - ), - bottom: const LinearBorderEdge(), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart b/packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart new file mode 100644 index 00000000..bbff9834 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_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 '../buttons/stream_button.dart'; +import 'stream_header_toolbar.dart'; + +/// A top-of-screen header for full-page surfaces in the Stream design system. +/// +/// [StreamAppBar] arranges an optional centered [title] (and optional +/// [subtitle]) between optional [leading] and [trailing] widget slots — +/// 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. +/// +/// When [leading] is null and [automaticallyImplyLeading] is true (the +/// default), a dismissal button is inserted if the enclosing route can pop: +/// +/// * Inside a fullscreen dialog → a cross (`xmark`). +/// * Otherwise on iOS-style platforms (iOS / macOS) → a back chevron. +/// * Otherwise (Android / web / desktop) → an arrow-left. +/// +/// A hairline `borderSubtle` border is drawn along the bottom edge to +/// separate the bar from page content — it's part of the bar's identity +/// rather than a configurable divider. +/// +/// [StreamAppBar] implements [PreferredSizeWidget] so it can be passed +/// directly to [Scaffold.appBar]. +/// +/// {@tool snippet} +/// +/// Use as a [Scaffold.appBar] with a centered title — the leading back button +/// is auto-implied: +/// +/// ```dart +/// Scaffold( +/// appBar: StreamAppBar(title: const Text('Details')), +/// body: const _DetailsBody(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamAppBar] uses [StreamAppBarThemeData] for default styling — colours, +/// padding, spacing, title/subtitle text styles, and per-slot button style +/// propagation. Defaults are derived from [StreamColorScheme], +/// [StreamTextTheme], and [StreamSpacing]. +/// +/// See also: +/// +/// * [StreamAppBarThemeData], for customizing appearance globally. +/// * [StreamAppBarTheme], for overriding theme in a subtree. +/// * [StreamSheetHeader], the equivalent for bottom-sheet / dialog chrome. +/// * [DefaultStreamAppBar], the default visual implementation. +class StreamAppBar extends StatelessWidget implements PreferredSizeWidget { + /// Creates a Stream app bar. + StreamAppBar({ + super.key, + Widget? leading, + bool automaticallyImplyLeading = true, + Widget? title, + Widget? subtitle, + Widget? trailing, + bool primary = true, + StreamAppBarStyle? style, + }) : props = .new( + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, + ); + + /// The properties that configure this app bar. + final StreamAppBarProps props; + + @override + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).appBar; + if (builder != null) return builder(context, props); + return DefaultStreamAppBar(props: props); + } +} + +/// Properties for configuring a [StreamAppBar]. +/// +/// This class holds all configuration options for an app bar, allowing them +/// to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamAppBar], which uses these properties. +/// * [DefaultStreamAppBar], the default implementation. +class StreamAppBarProps { + /// Creates properties for an app bar. + const StreamAppBarProps({ + this.leading, + this.automaticallyImplyLeading = true, + this.title, + this.subtitle, + this.trailing, + this.primary = true, + this.style, + }); + + /// 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. + /// + /// When null and [automaticallyImplyLeading] is true, a default dismissal + /// button is inserted if the enclosing route can pop — a cross on + /// fullscreen dialogs, a chevron on iOS-style platforms, an arrow-left + /// elsewhere. + final Widget? leading; + + /// Controls whether a default dismissal button is shown when [leading] is + /// null. + /// + /// When true (the default), a button is inserted as the leading widget if + /// the enclosing route can pop. The icon depends on the surface — see + /// [StreamAppBar] for the full resolution table. + final bool automaticallyImplyLeading; + + /// The primary content of the app bar. + /// + /// Typically a [Text] widget. Its text style is resolved from + /// [StreamAppBarStyle.titleTextStyle] (defaults to `textTheme.headingSm` on + /// `colorScheme.textPrimary`). + final Widget? title; + + /// Additional content displayed below the [title]. + /// + /// Typically a [Text] widget. Its text style is resolved from + /// [StreamAppBarStyle.subtitleTextStyle] (defaults to + /// `textTheme.captionDefault` on `colorScheme.textSecondary`). + final Widget? subtitle; + + /// 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. + final Widget? trailing; + + /// Whether this app bar is the topmost chrome of its surface. + /// + /// When true (the default), the app bar wraps itself in a + /// `SafeArea(bottom: false)` so it clears the system top inset + /// (status bar / notch) and horizontal insets. + /// + /// Set to false when the app bar isn't at the top of its surface (e.g. + /// inside a sub-section of a page that has already consumed the top + /// inset) so it doesn't double-pad. + final bool primary; + + /// The visual style applied to this app bar. + /// + /// Resolution order per field: this [style] → ambient [StreamAppBarTheme] + /// → token-backed defaults. + final StreamAppBarStyle? style; +} + +/// The default implementation of [StreamAppBar]. +/// +/// This widget renders the app bar with theming support from +/// [StreamAppBarTheme]. It's used as the default factory implementation in +/// [StreamComponentFactory]. +/// +/// The bar uses [NavigationToolbar] internally so the title is centred in +/// the bar's full width (rather than the leftover space between leading and +/// trailing), and only shifts when it would overlap a slot — keeping the +/// title visually centred even when leading and trailing have different +/// widths. +/// +/// See also: +/// +/// * [StreamAppBar], the public API widget. +/// * [StreamAppBarProps], which configures this widget. +class DefaultStreamAppBar extends StatelessWidget { + /// Creates a default app bar with the given [props]. + const DefaultStreamAppBar({super.key, required this.props}); + + /// The properties that configure this app bar. + final StreamAppBarProps props; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + final style = context.streamAppBarTheme.style?.merge(props.style) ?? props.style; + final defaults = _StreamAppBarStyleDefaults(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; + + // Leading: caller-provided, or an auto-implied dismissal button when + // the enclosing route implies one. Fullscreen dialogs get a close + // cross (modal presentation); everything else gets the platform-aware + // back affordance. + var leading = props.leading; + if (leading == null && props.automaticallyImplyLeading) { + final parentRoute = ModalRoute.of(context); + if (parentRoute != null && parentRoute.impliesAppBarDismissal) { + // Platform-aware back affordance — chevron on iOS-style + // platforms, arrow-left elsewhere. + final backIcon = switch (Theme.of(context).platform) { + .iOS || .macOS => icons.chevronLeft, + _ => icons.arrowLeft, + }; + final useCloseIcon = parentRoute is PageRoute && parentRoute.fullscreenDialog; + leading = StreamButton.icon( + type: .ghost, + style: .secondary, + icon: Icon(useCloseIcon ? icons.xmark : backIcon), + onPressed: Navigator.of(context).maybePop, + ); + } + } + + 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.appBar] slot). + Widget bar = SizedBox( + height: kStreamHeaderHeight, + child: StreamHeaderToolbar( + padding: effectivePadding, + spacing: effectiveSpacing, + leading: leading, + middle: middle, + trailing: trailing, + ), + ); + + if (props.primary) { + bar = SafeArea(bottom: false, child: bar); + } + + // The bar's bottom 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( + bottom: BorderSide(color: context.streamColorScheme.borderSubtle), + ), + ), + child: bar, + ); + } +} + +// Default style values for [StreamAppBar]. +// +// These defaults are used when no explicit value is provided via constructor +// parameters or [StreamAppBarStyle]. The defaults are context-aware and +// use values from [StreamColorScheme], [StreamTextTheme], and [StreamSpacing]. +class _StreamAppBarStyleDefaults extends StreamAppBarStyle { + _StreamAppBarStyleDefaults(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_header_toolbar.dart b/packages/stream_core_flutter/lib/src/components/header/stream_header_toolbar.dart new file mode 100644 index 00000000..30a6e5ac --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/header/stream_header_toolbar.dart @@ -0,0 +1,132 @@ +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; + +/// Three-slot horizontal layout shared by [StreamAppBar] 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 +/// bar's full width — so an asymmetric leading and trailing don't shift the +/// title off-centre. The middle is constrained to the symmetric space +/// reserved between the side slots so it never overlaps either of them. +/// +/// 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]). +/// +/// [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({ + super.key, + this.leading, + this.middle, + this.trailing, + this.padding = EdgeInsets.zero, + this.spacing = 0, + }); + + /// The widget anchored at the start edge. + final Widget? leading; + + /// The widget centred in the bar's full inner width. + final Widget? middle; + + /// The widget anchored at the end edge. + final Widget? trailing; + + /// Padding applied around all three slots. + final EdgeInsetsGeometry padding; + + /// Minimum gap reserved between the middle slot and either side slot. + final double spacing; + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.of(context); + return CustomMultiChildLayout( + delegate: _StreamHeaderToolbarLayout( + spacing: spacing, + textDirection: textDirection, + padding: padding.resolve(textDirection), + ), + children: [ + if (leading != null) LayoutId(id: _Slot.leading, child: leading!), + if (middle != null) LayoutId(id: _Slot.middle, child: middle!), + if (trailing != null) LayoutId(id: _Slot.trailing, child: trailing!), + ], + ); + } +} + +enum _Slot { leading, middle, trailing } + +class _StreamHeaderToolbarLayout extends MultiChildLayoutDelegate { + _StreamHeaderToolbarLayout({ + required this.padding, + required this.spacing, + required this.textDirection, + }); + + final EdgeInsets padding; + final double spacing; + final TextDirection textDirection; + + @override + Size getSize(BoxConstraints constraints) => constraints.biggest; + + @override + void performLayout(Size size) { + final innerLeft = padding.left; + final innerRight = size.width - padding.right; + final innerWidth = math.max(0, innerRight - innerLeft); + final innerHeight = math.max(0, size.height - padding.top - padding.bottom); + final isLtr = textDirection == TextDirection.ltr; + + var leadingWidth = 0.0; + var trailingWidth = 0.0; + + if (hasChild(_Slot.leading)) { + final slotSize = layoutChild(_Slot.leading, .loose(Size(innerWidth, innerHeight))); + leadingWidth = slotSize.width; + final dx = isLtr ? innerLeft : innerRight - leadingWidth; + final dy = padding.top + (innerHeight - slotSize.height) / 2; + positionChild(_Slot.leading, Offset(dx, dy)); + } + + if (hasChild(_Slot.trailing)) { + final slotSize = layoutChild(_Slot.trailing, .loose(Size(innerWidth, innerHeight))); + trailingWidth = slotSize.width; + final dx = isLtr ? innerRight - trailingWidth : innerLeft; + final dy = padding.top + (innerHeight - slotSize.height) / 2; + positionChild(_Slot.trailing, Offset(dx, dy)); + } + + if (hasChild(_Slot.middle)) { + // Reserve symmetric space on both sides — based on the wider of the + // two side slots — so the middle stays centred even when leading and + // trailing differ in width. Trades a slightly tighter middle for + // perfect centring. + final reservedSide = math.max(leadingWidth, trailingWidth); + final maxMiddleWidth = math.max(0, innerWidth - 2 * reservedSide - 2 * spacing); + final slotSize = layoutChild(_Slot.middle, .loose(Size(maxMiddleWidth, innerHeight))); + final dx = (size.width - slotSize.width) / 2; + final dy = padding.top + (innerHeight - slotSize.height) / 2; + positionChild(_Slot.middle, Offset(dx, dy)); + } + } + + @override + bool shouldRelayout(covariant _StreamHeaderToolbarLayout oldDelegate) { + return padding != oldDelegate.padding || + spacing != oldDelegate.spacing || + textDirection != oldDelegate.textDirection; + } +} diff --git a/packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart b/packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart index 36699088..0c0231e8 100644 --- a/packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart +++ b/packages/stream_core_flutter/lib/src/components/header/stream_sheet_header.dart @@ -8,8 +8,8 @@ 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 '../common/stream_visibility.dart'; import '../sheet/stream_sheet.dart'; +import 'stream_header_toolbar.dart'; /// A header for bottom sheets, modals, and dialogs in the Stream design /// system. @@ -31,13 +31,17 @@ import '../sheet/stream_sheet.dart'; /// first route), a cross (`xmark`) is shown — pressing it closes the /// entire sheet. /// * Inside a stacked [StreamSheetRoute] (one that covers another sheet), -/// a back chevron is shown — pressing it pops back to the previous -/// sheet. -/// * Inside deeper nested routes within a [StreamSheetRoute], a back -/// chevron is shown — pressing it pops one level inside the sheet. +/// a platform-aware back affordance is shown — pressing it pops back to +/// the previous sheet. +/// * Inside deeper nested routes within a [StreamSheetRoute], a +/// platform-aware back affordance is shown — pressing it pops one level +/// inside the sheet. /// * On any other modal surface (bottom sheets, dialogs, fullscreen /// dialogs), a cross is shown. -/// * On regular pushed pages, a back chevron is shown. +/// * On regular pushed pages, a platform-aware back affordance is shown. +/// +/// The platform-aware back affordance is a chevron on iOS / macOS and an +/// arrow-left on Android / web / desktop. /// /// The drag handle shown on iOS-style bottom sheets is intentionally *not* /// part of this widget — the sheet itself owns that affordance, which @@ -48,7 +52,7 @@ import '../sheet/stream_sheet.dart'; /// /// Use inside a [StreamSheetRoute] with a confirm action — the leading /// close button is auto-implied (cross at the root of a sheet, back -/// chevron when stacked over another sheet): +/// affordance when stacked over another sheet): /// /// ```dart /// showStreamSheet( @@ -83,7 +87,7 @@ import '../sheet/stream_sheet.dart'; /// * [StreamSheetHeaderTheme], for overriding theme in a subtree. /// * [StreamAppBar], the equivalent for top-level screen chrome. /// * [DefaultStreamSheetHeader], the default visual implementation. -class StreamSheetHeader extends StatelessWidget { +class StreamSheetHeader extends StatelessWidget implements PreferredSizeWidget { /// Creates a Stream sheet header. StreamSheetHeader({ super.key, @@ -107,6 +111,9 @@ class StreamSheetHeader extends StatelessWidget { /// The properties that configure this header. final StreamSheetHeaderProps props; + @override + Size get preferredSize => const Size.fromHeight(kStreamHeaderHeight); + @override Widget build(BuildContext context) { final builder = StreamComponentFactory.of(context).sheetHeader; @@ -143,17 +150,16 @@ class StreamSheetHeaderProps { /// symmetry. /// /// When null and [automaticallyImplyLeading] is true, a default dismissal - /// button is inserted if the enclosing route can pop — a cross on modal - /// surfaces, a back chevron on regular pushed pages. + /// button is inserted if the enclosing route can pop — see + /// [StreamSheetHeader] for the full resolution table. final Widget? leading; /// Controls whether a default dismissal button is shown when [leading] is /// null. /// /// When true (the default), a button is inserted as the leading widget if - /// the enclosing route can pop. The icon is a cross on modal surfaces - /// (bottom sheets, dialogs, fullscreen dialogs) and a back chevron on - /// regular pushed pages. + /// the enclosing route can pop. The icon depends on the surface — see + /// [StreamSheetHeader] for the full resolution table. final bool automaticallyImplyLeading; /// The primary content of the header. @@ -167,7 +173,7 @@ class StreamSheetHeaderProps { /// /// Typically a [Text] widget. Its text style is resolved from /// [StreamSheetHeaderThemeData.subtitleTextStyle] (defaults to - /// `textTheme.captionDefault` on `colorScheme.textTertiary`). + /// `textTheme.captionDefault` on `colorScheme.textSecondary`). final Widget? subtitle; /// A widget to display after the [title]. @@ -201,10 +207,10 @@ class StreamSheetHeaderProps { /// [StreamSheetHeaderTheme]. It's used as the default factory /// implementation in [StreamComponentFactory]. /// -/// When only one of [StreamSheetHeaderProps.leading] / -/// [StreamSheetHeaderProps.trailing] is provided, the opposite side -/// reserves a 48×48 spacer (via [StreamVisibility.hidden]) so the title -/// stays visually centered. +/// The title slot is centred in the header's full inner width via +/// [StreamHeaderToolbar], which reserves symmetric space around the +/// middle so an asymmetric leading and trailing don't shift the title +/// off-centre. /// /// See also: /// @@ -251,15 +257,22 @@ class DefaultStreamSheetHeader extends StatelessWidget { final parentRoute = ModalRoute.of(context); final sheetRoute = StreamSheetRoute.maybeOf(context); + // Platform-aware back affordance shared by every "go back" branch + // below — chevron on iOS-style platforms, arrow-left elsewhere. + final backIcon = switch (Theme.of(context).platform) { + .iOS || .macOS => icons.chevronLeft, + _ => icons.arrowLeft, + }; + IconData? icon; VoidCallback? onPressed; if (parentRoute is StreamSheetRoute) { // Header sits directly on a [StreamSheetRoute] (no nested nav // layer between us and the route). A stacked sheet's pop - // returns to the parent sheet — show a back chevron. A root + // returns to the parent sheet — show a back affordance. A root // sheet's pop dismisses it entirely — show a close cross. - icon = parentRoute.isStacked ? icons.chevronLeft : icons.xmark; + icon = parentRoute.isStacked ? backIcon : icons.xmark; onPressed = Navigator.of(context).maybePop; } else if (sheetRoute != null && parentRoute != null) { // Header is inside the enclosing sheet's nested navigator @@ -267,18 +280,21 @@ class DefaultStreamSheetHeader extends StatelessWidget { if (parentRoute.isFirst) { // First nested route: tapping the icon dismisses the *whole* // sheet via [popSheet]. Mirror the non-nested case for the - // icon — chevron when the enclosing sheet is stacked (pop - // reveals the parent), cross when it's a root sheet. - icon = sheetRoute.isStacked ? icons.chevronLeft : icons.xmark; + // icon — back affordance when the enclosing sheet is stacked + // (pop reveals the parent), cross when it's a root sheet. + icon = sheetRoute.isStacked ? backIcon : icons.xmark; onPressed = () => StreamSheetRoute.popSheet(context); } else { // Deeper nested route: pop one level inside the sheet. - icon = icons.chevronLeft; + icon = backIcon; onPressed = Navigator.of(context).maybePop; } } else if (parentRoute != null && parentRoute.impliesAppBarDismissal) { - final isRegularPage = parentRoute is PageRoute && !parentRoute.fullscreenDialog; - icon = isRegularPage ? icons.chevronLeft : icons.xmark; + // Regular pushed pages get the platform-aware back affordance. + // Anything else that implies dismissal (popup routes, dialogs, + // fullscreen dialogs, custom modal routes) gets a cross. + final useCloseIcon = parentRoute is! PageRoute || parentRoute.fullscreenDialog; + icon = useCloseIcon ? icons.xmark : backIcon; onPressed = Navigator.of(context).maybePop; } @@ -311,14 +327,6 @@ class DefaultStreamSheetHeader extends StatelessWidget { ); } - // When only one side is present, reserve a 48×48 spacer on the opposite - // side so the title stays visually centered. - if ((leading == null) != (trailing == null)) { - const spacer = SizedBox.square(dimension: kMinInteractiveDimension); - leading ??= StreamVisibility.hidden.apply(spacer); - trailing ??= StreamVisibility.hidden.apply(spacer); - } - Widget? titleWidget; if (props.title case final title?) { titleWidget = AnimatedDefaultTextStyle( @@ -339,21 +347,27 @@ class DefaultStreamSheetHeader extends StatelessWidget { ); } - Widget header = Padding( - padding: effectivePadding, - child: Row( + Widget? middle; + if (titleWidget != null || subtitleWidget != null) { + middle = Column( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [?titleWidget, ?subtitleWidget], + ); + } + + // The header advertises a fixed height via [PreferredSizeWidget]; the + // [SizedBox] enforces it for callers that don't honour the contract + // (sheet headers usually live inside a [Column], not a slot that reads + // [PreferredSizeWidget.preferredSize]). + Widget header = SizedBox( + height: kStreamHeaderHeight, + child: StreamHeaderToolbar( + padding: effectivePadding, spacing: effectiveSpacing, - children: [ - ?leading, - Expanded( - child: Column( - mainAxisSize: .min, - spacing: spacing.xxs, - children: [?titleWidget, ?subtitleWidget], - ), - ), - ?trailing, - ], + leading: leading, + middle: middle, + trailing: trailing, ), ); @@ -395,5 +409,5 @@ class _StreamSheetHeaderStyleDefaults extends StreamSheetHeaderStyle { TextStyle get titleTextStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); @override - TextStyle get subtitleTextStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textTertiary); + TextStyle get subtitleTextStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textSecondary); } diff --git a/packages/stream_core_flutter/lib/src/components/sheet/stream_sheet.dart b/packages/stream_core_flutter/lib/src/components/sheet/stream_sheet.dart index a4c01daa..a479c11f 100644 --- a/packages/stream_core_flutter/lib/src/components/sheet/stream_sheet.dart +++ b/packages/stream_core_flutter/lib/src/components/sheet/stream_sheet.dart @@ -615,6 +615,24 @@ class StreamSheet extends StatelessWidget { } } +/// Plain value holder for the sheet's available height — the height of +/// the slot the sheet renders into, captured during layout. +/// +/// Drag handlers convert pixel deltas / velocities into sheet fractions +/// by dividing by [availableHeight]. We snapshot it through a +/// [LayoutBuilder] in [StreamSheetRoute.buildPage] (where parent +/// constraints are stable across child rebuilds) instead of reading +/// `context.size` at end-of-gesture, which can throw if a body +/// `setState` marked the render object dirty mid-drag. +/// +/// Modeled on Flutter's `DraggableScrollableSheet` extent — same idea, +/// trimmed down to just the height field we actually need. +class _StreamSheetExtent { + /// Height of the slot the sheet body fills. Set by the route's + /// `LayoutBuilder` and read lazily by the drag handlers. + double availableHeight = 0; +} + /// Modal route for a Stream-styled sheet. /// /// The sheet slides up from the bottom of the screen and rests just @@ -756,6 +774,11 @@ class StreamSheetRoute extends PageRoute { /// Set automatically by [showStreamSheet]. Pass `null` to disable. final CapturedThemes? capturedThemes; + /// Tracks the sheet's available height (set by [LayoutBuilder] in + /// [buildPage], read by the drag handlers). Stable across body + /// rebuilds — see [_StreamSheetExtent]. + final _extent = _StreamSheetExtent(); + /// Whether this sheet was pushed on top of another [StreamSheetRoute]. /// /// Stacked sheets render a back-chevron in their auto-implied @@ -823,13 +846,24 @@ class StreamSheetRoute extends PageRoute { Animation animation, Animation secondaryAnimation, ) { - final content = _StreamDraggableScrollableSheet( - enableDrag: () => enableDrag, - popDragController: controller!, - navigator: navigator!, - getIsCurrent: () => isCurrent, - getIsActive: () => isActive, - builder: _buildBodyWithDragHandle, + // Wrap the body in a LayoutBuilder so we can capture the sheet's + // available height during layout (constraints flow down from the + // route's outer SafeArea / DisplayFeatureSubScreen and are stable + // across body rebuilds). The drag handlers read this — never the + // rendered size, which can be dirty mid-gesture. + final content = LayoutBuilder( + builder: (context, constraints) { + _extent.availableHeight = constraints.maxHeight; + return _StreamDraggableScrollableSheet( + extent: _extent, + enableDrag: () => enableDrag, + popDragController: controller!, + navigator: navigator!, + getIsCurrent: () => isCurrent, + getIsActive: () => isActive, + builder: _buildBodyWithDragHandle, + ); + }, ); Widget sheet = StreamSheet( @@ -945,6 +979,7 @@ class StreamSheetRoute extends PageRoute { // controller. if (!enableDrag) { handle = _StreamDragGestureDetector( + extent: _extent, enabledCallback: () => true, onStartPopGesture: _startPopGesture, onDragStart: onDragStart, @@ -953,15 +988,23 @@ class StreamSheetRoute extends PageRoute { ); } - // The body fills the sheet; the drag handle floats over the top - // with a tiny offset. Stream's chrome (sheet header, etc.) is - // expected to leave space at the top for the handle. + // The drag handle floats over the top of the body with a tiny + // offset. Stream's chrome (sheet header, etc.) is expected to leave + // space at the top for the handle. + // + // The Stack uses the default [StackFit.loose] (and *not* + // [StackFit.expand]) so the body's intrinsic height is preserved — + // a body that uses [MainAxisSize.min] correctly shrink-wraps the + // sheet, while a body that wants to fill the screen still does so + // via [Expanded] / [MainAxisSize.max] from inside. + // + // The handle is placed via the Stack's [alignment] (rather than a + // child [Align]) so it doesn't expand to fill loose constraints — + // its natural 36×9 size is used and positioned at top-center of the + // Stack, which makes the body's size determine the Stack's size. return Stack( - fit: StackFit.expand, - children: [ - body, - Align(alignment: Alignment.topCenter, child: handle), - ], + alignment: Alignment.topCenter, + children: [body, handle], ); } @@ -979,6 +1022,7 @@ class StreamSheetRoute extends PageRoute { isStacked: isStacked, topPadding: topPaddingFor(context), child: _StreamDragGestureDetector( + extent: _extent, enabledCallback: () => enableDrag, onStartPopGesture: _startPopGesture, onDragStart: onDragStart, @@ -1059,6 +1103,7 @@ class StreamSheetDragHandle extends StatelessWidget { class _StreamDragGestureDetector extends StatefulWidget { const _StreamDragGestureDetector({ + required this.extent, required this.enabledCallback, required this.onStartPopGesture, required this.child, @@ -1066,6 +1111,7 @@ class _StreamDragGestureDetector extends StatefulWidget { this.onDragEnd, }); + final _StreamSheetExtent extent; final Widget child; final ValueGetter enabledCallback; final ValueGetter<_StreamDragGestureController> onStartPopGesture; @@ -1128,11 +1174,11 @@ class _StreamDragGestureDetectorState extends State<_StreamDragGestureDetector> void _handleDragUpdate(DragUpdateDetails details) { assert(mounted); assert(_dragGestureController != null); - final size = context.size; - if (size == null || size.height <= 0) return; + final sheetHeight = widget.extent.availableHeight; + if (sheetHeight <= 0) return; _dragGestureController?.dragUpdate( details.primaryDelta!, - sheetHeight: size.height, + sheetHeight: sheetHeight, stretchPixels: context.streamSpacing.xs, stretchController: _transitionScope?.stretchController, ); @@ -1141,8 +1187,11 @@ class _StreamDragGestureDetectorState extends State<_StreamDragGestureDetector> void _handleDragEnd(DragEndDetails details) { assert(mounted); assert(_dragGestureController != null); - final size = context.size; - final velocity = size != null && size.height > 0 ? details.velocity.pixelsPerSecond.dy / size.height : 0.0; + // Read the height the route's LayoutBuilder snapshotted during the + // last layout pass. Stable across body rebuilds — not affected by + // a mid-gesture setState that would dirty the rendered size. + final sheetHeight = widget.extent.availableHeight; + final velocity = sheetHeight > 0 ? details.velocity.pixelsPerSecond.dy / sheetHeight : 0.0; final isClosing = _dragGestureController?.dragEnd( velocity, @@ -1305,6 +1354,7 @@ class _StreamDragGestureController { // gesture-detector path on the sheet's chrome owns its own. class _StreamDraggableScrollableSheet extends StatefulWidget { const _StreamDraggableScrollableSheet({ + required this.extent, required this.enableDrag, required this.popDragController, required this.navigator, @@ -1313,6 +1363,7 @@ class _StreamDraggableScrollableSheet extends StatefulWidget { required this.builder, }); + final _StreamSheetExtent extent; final ValueGetter enableDrag; final AnimationController popDragController; final NavigatorState navigator; @@ -1368,11 +1419,11 @@ class _StreamDraggableScrollableSheetState extends State<_StreamDraggableScrolla assert(mounted); final dragController = _dragGestureController; if (dragController == null) return; - final size = context.size; - if (size == null || size.height <= 0) return; + final sheetHeight = widget.extent.availableHeight; + if (sheetHeight <= 0) return; dragController.dragUpdate( delta, - sheetHeight: size.height, + sheetHeight: sheetHeight, stretchPixels: context.streamSpacing.xs, ); } @@ -1382,8 +1433,11 @@ class _StreamDraggableScrollableSheetState extends State<_StreamDraggableScrolla final dragController = _dragGestureController; if (dragController == null) return; _dragGestureController = null; - final size = context.size; - final sheetHeight = size != null && size.height > 0 ? size.height : 1.0; + // Read the height the route's LayoutBuilder snapshotted during the + // last layout pass. Stable across body rebuilds — not affected by + // a mid-gesture setState that would dirty the rendered size. + final cached = widget.extent.availableHeight; + final sheetHeight = cached > 0 ? cached : 1.0; // Convert scroll-position velocity (negative = finger moved down, // pixels/sec) to the sheet-fraction finger-down velocity that // [_StreamDragGestureController.dragEnd] expects. diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 148dc3e9..fdaeffb2 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -1,5 +1,6 @@ export 'factory/stream_component_factory.dart'; +export 'theme/components/stream_app_bar_theme.dart'; export 'theme/components/stream_audio_waveform_theme.dart'; export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart new file mode 100644 index 00000000..31341006 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart @@ -0,0 +1,185 @@ +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_app_bar_theme.g.theme.dart'; + +/// Applies an app bar theme to descendant [StreamAppBar] widgets. +/// +/// Wrap a subtree with [StreamAppBarTheme] to override app bar styling. +/// Access the merged theme using [BuildContext.streamAppBarTheme]. +/// +/// {@tool snippet} +/// +/// Override app bar background for a specific subtree: +/// +/// ```dart +/// StreamAppBarTheme( +/// data: StreamAppBarThemeData( +/// style: StreamAppBarStyle(backgroundColor: Color(0xFFF6F7F9)), +/// ), +/// child: Scaffold( +/// appBar: StreamAppBar(title: Text('Details')), +/// body: ..., +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAppBarThemeData], which describes the app bar theme. +/// * [StreamAppBarStyle], the reusable visual style embedded by the theme. +/// * [StreamAppBar], the widget affected by this theme. +class StreamAppBarTheme extends InheritedTheme { + /// Creates an app bar theme that controls descendant app bars. + const StreamAppBarTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The app bar theme data for descendant widgets. + final StreamAppBarThemeData data; + + /// Returns the [StreamAppBarThemeData] merged from local and global themes. + /// + /// Local values from the nearest [StreamAppBarTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamAppBarThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).appBarTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamAppBarTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamAppBarTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamAppBar] widgets. +/// +/// Wraps a [StreamAppBarStyle] so it can be served by [StreamAppBarTheme] +/// and slotted into [StreamTheme] alongside other component theme data +/// classes. +/// +/// {@tool snippet} +/// +/// Customize app bar appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// appBarTheme: StreamAppBarThemeData( +/// style: StreamAppBarStyle( +/// padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAppBarStyle], the reusable visual style embedded here. +/// * [StreamAppBarTheme], for overriding the theme in a widget subtree. +/// * [StreamAppBar], the widget that uses this theme data. +@themeGen +@immutable +class StreamAppBarThemeData with _$StreamAppBarThemeData { + /// Creates app bar theme data. + const StreamAppBarThemeData({this.style}); + + /// Visual styling for the app bar. + final StreamAppBarStyle? style; + + /// Linearly interpolate between two [StreamAppBarThemeData] objects. + static StreamAppBarThemeData? lerp( + StreamAppBarThemeData? a, + StreamAppBarThemeData? b, + double t, + ) => _$StreamAppBarThemeData.lerp(a, b, t); +} + +/// Visual styling properties for a [StreamAppBar]. +/// +/// Defines the appearance of the app bar — background colour, padding, +/// inter-slot spacing, title and subtitle text styles, and per-slot button +/// style propagation. +/// +/// Exposed separately from [StreamAppBarThemeData] so other theme data classes +/// can embed an app-bar style via a typed field. +/// +/// {@tool snippet} +/// +/// Compose a style and hand it to an app bar theme: +/// +/// ```dart +/// StreamAppBarStyle( +/// backgroundColor: Color(0xFFFFFFFF), +/// padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), +/// spacing: 8, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamAppBarThemeData], which wraps this style for theming. +/// * [StreamAppBar], which uses this styling. +@themeGen +@immutable +class StreamAppBarStyle with _$StreamAppBarStyle { + /// Creates an app bar style with optional property overrides. + const StreamAppBarStyle({ + this.backgroundColor, + this.padding, + this.spacing, + this.titleTextStyle, + this.subtitleTextStyle, + this.leadingStyle, + this.trailingStyle, + }); + + /// The background colour of the app bar. + final Color? backgroundColor; + + /// The padding around the header's content row. + final EdgeInsetsGeometry? padding; + + /// The horizontal space between the leading, heading, and trailing slots. + final double? spacing; + + /// The text style for [StreamAppBar.title]. + final TextStyle? titleTextStyle; + + /// The text style for [StreamAppBar.subtitle]. + final TextStyle? subtitleTextStyle; + + /// The button style for any [StreamButton] rendered in + /// [StreamAppBar.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 + /// [StreamAppBar.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 [StreamAppBarStyle] objects. + static StreamAppBarStyle? lerp( + StreamAppBarStyle? a, + StreamAppBarStyle? b, + double t, + ) => _$StreamAppBarStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart new file mode 100644 index 00000000..5f0e722e --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_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_app_bar_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamAppBarThemeData { + bool get canMerge => true; + + static StreamAppBarThemeData? lerp( + StreamAppBarThemeData? a, + StreamAppBarThemeData? 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 StreamAppBarThemeData( + style: StreamAppBarStyle.lerp(a.style, b.style, t), + ); + } + + StreamAppBarThemeData copyWith({StreamAppBarStyle? style}) { + final _this = (this as StreamAppBarThemeData); + + return StreamAppBarThemeData(style: style ?? _this.style); + } + + StreamAppBarThemeData merge(StreamAppBarThemeData? other) { + final _this = (this as StreamAppBarThemeData); + + 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 StreamAppBarThemeData); + final _other = (other as StreamAppBarThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamAppBarThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamAppBarStyle { + bool get canMerge => true; + + static StreamAppBarStyle? lerp( + StreamAppBarStyle? a, + StreamAppBarStyle? 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 StreamAppBarStyle( + 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, + ), + ); + } + + StreamAppBarStyle copyWith({ + Color? backgroundColor, + EdgeInsetsGeometry? padding, + double? spacing, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + StreamButtonThemeStyle? leadingStyle, + StreamButtonThemeStyle? trailingStyle, + }) { + final _this = (this as StreamAppBarStyle); + + return StreamAppBarStyle( + 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, + ); + } + + StreamAppBarStyle merge(StreamAppBarStyle? other) { + final _this = (this as StreamAppBarStyle); + + 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 StreamAppBarStyle); + final _other = (other as StreamAppBarStyle); + + 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 StreamAppBarStyle); + + 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/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 0b370595..ffd5eeb9 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'components/stream_app_bar_theme.dart'; import 'components/stream_audio_waveform_theme.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; @@ -104,6 +105,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, // Components themes + StreamAppBarThemeData? appBarTheme, StreamAudioWaveformThemeData? audioWaveformTheme, StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, @@ -146,6 +148,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { boxShadow ??= isDark ? StreamBoxShadow.dark() : StreamBoxShadow.light(); // Components + appBarTheme ??= const StreamAppBarThemeData(); audioWaveformTheme ??= const StreamAudioWaveformThemeData(); avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); @@ -182,6 +185,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { colorScheme: colorScheme, textTheme: textTheme, boxShadow: boxShadow, + appBarTheme: appBarTheme, audioWaveformTheme: audioWaveformTheme, avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, @@ -232,6 +236,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.colorScheme, required this.textTheme, required this.boxShadow, + required this.appBarTheme, required this.audioWaveformTheme, required this.avatarTheme, required this.badgeCountTheme, @@ -318,6 +323,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The box shadow (elevation) values for this theme. final StreamBoxShadow boxShadow; + /// The app bar theme for this theme. + final StreamAppBarThemeData appBarTheme; + /// The audio waveform theme for this theme. final StreamAudioWaveformThemeData audioWaveformTheme; @@ -426,6 +434,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { colorScheme: colorScheme, textTheme: newTextTheme, boxShadow: boxShadow, + appBarTheme: appBarTheme, audioWaveformTheme: audioWaveformTheme, avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, 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 a3f644a3..fdc48da8 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 @@ -20,6 +20,7 @@ mixin _$StreamTheme on ThemeExtension { StreamColorScheme? colorScheme, StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, + StreamAppBarThemeData? appBarTheme, StreamAudioWaveformThemeData? audioWaveformTheme, StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, @@ -58,6 +59,7 @@ mixin _$StreamTheme on ThemeExtension { colorScheme: colorScheme ?? _this.colorScheme, textTheme: textTheme ?? _this.textTheme, boxShadow: boxShadow ?? _this.boxShadow, + appBarTheme: appBarTheme ?? _this.appBarTheme, audioWaveformTheme: audioWaveformTheme ?? _this.audioWaveformTheme, avatarTheme: avatarTheme ?? _this.avatarTheme, badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, @@ -112,6 +114,11 @@ mixin _$StreamTheme on ThemeExtension { (_this.colorScheme.lerp(other.colorScheme, t) as StreamColorScheme), textTheme: (_this.textTheme.lerp(other.textTheme, t) as StreamTextTheme), boxShadow: StreamBoxShadow.lerp(_this.boxShadow, other.boxShadow, t)!, + appBarTheme: StreamAppBarThemeData.lerp( + _this.appBarTheme, + other.appBarTheme, + t, + )!, audioWaveformTheme: StreamAudioWaveformThemeData.lerp( _this.audioWaveformTheme, other.audioWaveformTheme, @@ -262,6 +269,7 @@ mixin _$StreamTheme on ThemeExtension { _other.colorScheme == _this.colorScheme && _other.textTheme == _this.textTheme && _other.boxShadow == _this.boxShadow && + _other.appBarTheme == _this.appBarTheme && _other.audioWaveformTheme == _this.audioWaveformTheme && _other.avatarTheme == _this.avatarTheme && _other.badgeCountTheme == _this.badgeCountTheme && @@ -304,6 +312,7 @@ mixin _$StreamTheme on ThemeExtension { _this.colorScheme, _this.textTheme, _this.boxShadow, + _this.appBarTheme, _this.audioWaveformTheme, _this.avatarTheme, _this.badgeCountTheme, 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 4e357e4f..97c69198 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 @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import 'components/stream_app_bar_theme.dart'; import 'components/stream_audio_waveform_theme.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; @@ -80,6 +81,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamBoxShadow] from the current theme. StreamBoxShadow get streamBoxShadow => streamTheme.boxShadow; + /// Returns the [StreamAppBarThemeData] from the nearest ancestor. + StreamAppBarThemeData get streamAppBarTheme => StreamAppBarTheme.of(this); + /// Returns the [StreamAudioWaveformThemeData] from the nearest ancestor. StreamAudioWaveformThemeData get streamAudioWaveformTheme => StreamAudioWaveformTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_dark_matrix.png b/packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_dark_matrix.png deleted file mode 100644 index 3aaf5992..00000000 Binary files a/packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_dark_matrix.png and /dev/null differ diff --git a/packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_light_matrix.png b/packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_light_matrix.png deleted file mode 100644 index b5ad58c2..00000000 Binary files a/packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_light_matrix.png and /dev/null differ diff --git a/packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart b/packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart new file mode 100644 index 00000000..bc4fb3e1 --- /dev/null +++ b/packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter/foundation.dart'; +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('StreamAppBar auto-implied leading', () { + testWidgets('does nothing when route cannot pop', (tester) async { + await tester.pumpWidget( + _withStreamTheme(Scaffold(appBar: StreamAppBar(title: const Text('Title')))), + ); + + // Root route of MaterialApp — canPop() is false, so no back button. + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('inserts arrow-left on a regular pushed route on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(StreamButton), findsOneWidget); + expect(find.byIcon(StreamIconData.arrowLeft), findsOneWidget); + expect(find.byIcon(StreamIconData.chevronLeft), findsNothing); + expect(find.byIcon(StreamIconData.xmark), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('inserts back chevron on a regular pushed route on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(StreamButton), findsOneWidget); + expect(find.byIcon(StreamIconData.chevronLeft), findsOneWidget); + expect(find.byIcon(StreamIconData.arrowLeft), findsNothing); + expect(find.byIcon(StreamIconData.xmark), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('inserts cross icon on a fullscreen dialog', (tester) async { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen(fullscreenDialog: true))); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(StreamButton), findsOneWidget); + expect(find.byIcon(StreamIconData.xmark), findsOneWidget); + expect(find.byIcon(StreamIconData.chevronLeft), findsNothing); + }); + + testWidgets('caller-provided leading suppresses the default', (tester) async { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen(customLeading: true))); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // One, not two — the caller's widget replaces the default. + expect(find.byKey(const ValueKey('custom-leading')), findsOneWidget); + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('automaticallyImplyLeading: false suppresses the default', (tester) async { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen(implyLeading: false))); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(StreamButton), findsNothing); + }); + }); + + group('StreamAppBar 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: StreamAppBarTheme( + data: const StreamAppBarThemeData( + style: StreamAppBarStyle(titleTextStyle: themeTitleStyle), + ), + child: Scaffold( + appBar: StreamAppBar( + automaticallyImplyLeading: false, + title: const Text('Title'), + subtitle: const Text('Subtitle'), + style: const StreamAppBarStyle(padding: propsPadding), + ), + ), + ), + ), + ); + + // Props win for padding (the bar passes its resolved padding through + // to the [StreamHeaderToolbar]'s `padding` property). + final toolbar = tester.widget( + find.descendant( + of: find.byType(StreamAppBar), + matching: find.byType(StreamHeaderToolbar), + ), + ); + 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)); + }, + ); + }); +} + +class _LauncherScreen extends StatelessWidget { + const _LauncherScreen({ + this.customLeading = false, + this.implyLeading = true, + this.fullscreenDialog = false, + }); + + final bool customLeading; + final bool implyLeading; + final bool fullscreenDialog; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Builder( + builder: (context) => TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: fullscreenDialog, + builder: (_) => Scaffold( + appBar: StreamAppBar( + automaticallyImplyLeading: implyLeading, + leading: customLeading + ? const SizedBox(key: ValueKey('custom-leading'), width: 40, height: 40) + : null, + title: const Text('Pushed'), + ), + ), + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/test/components/header/stream_sheet_header_golden_test.dart b/packages/stream_core_flutter/test/components/header/stream_sheet_header_golden_test.dart deleted file mode 100644 index b341582a..00000000 --- a/packages/stream_core_flutter/test/components/header/stream_sheet_header_golden_test.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; - -void main() { - group('StreamSheetHeader Golden Tests', () { - goldenTest( - 'renders light theme slot matrix', - fileName: 'stream_sheet_header_light_matrix', - builder: () => GoldenTestGroup( - scenarioConstraints: const BoxConstraints(maxWidth: 360), - children: [ - for (final scenario in _scenarios) - GoldenTestScenario( - name: scenario.name, - child: _buildInTheme(scenario.build()), - ), - ], - ), - ); - - goldenTest( - 'renders dark theme slot matrix', - fileName: 'stream_sheet_header_dark_matrix', - builder: () => GoldenTestGroup( - scenarioConstraints: const BoxConstraints(maxWidth: 360), - children: [ - for (final scenario in _scenarios) - GoldenTestScenario( - name: scenario.name, - child: _buildInTheme( - scenario.build(), - brightness: Brightness.dark, - ), - ), - ], - ), - ); - }); -} - -typedef _Scenario = ({String name, Widget Function() build}); - -final List<_Scenario> _scenarios = [ - ( - name: 'title_only', - build: () => StreamSheetHeader(title: const Text('Details')), - ), - ( - name: 'title_and_subtitle', - build: () => StreamSheetHeader( - title: const Text('Details'), - subtitle: const Text('Additional information'), - ), - ), - ( - name: 'title_with_leading', - build: () => StreamSheetHeader( - leading: _placeholder(), - title: const Text('Details'), - ), - ), - ( - name: 'title_with_trailing', - build: () => StreamSheetHeader( - title: const Text('Details'), - trailing: _placeholder(color: const Color(0xFF005FFF)), - ), - ), - ( - name: 'title_with_both_sides', - build: () => StreamSheetHeader( - leading: _placeholder(), - title: const Text('Details'), - trailing: _placeholder(color: const Color(0xFF005FFF)), - ), - ), - ( - name: 'full_with_subtitle', - build: () => StreamSheetHeader( - leading: _placeholder(), - title: const Text('Edit profile'), - subtitle: const Text('Tap to change your avatar'), - trailing: _placeholder(color: const Color(0xFF005FFF)), - ), - ), - ( - name: 'long_title_with_both_sides', - build: () => StreamSheetHeader( - leading: _placeholder(), - title: const Text( - 'A rather long title that should ellipsize gracefully', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: _placeholder(color: const Color(0xFF005FFF)), - ), - ), - ( - name: 'no_title_only_actions', - build: () => StreamSheetHeader( - leading: _placeholder(), - trailing: _placeholder(color: const Color(0xFF005FFF)), - ), - ), - ( - name: 'subtitle_only', - build: () => StreamSheetHeader( - subtitle: const Text('Subtitle without a title'), - ), - ), -]; - -Widget _placeholder({Color color = const Color(0xFFD5DBE1)}) { - return Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ); -} - -Widget _buildInTheme( - Widget child, { - Brightness brightness = Brightness.light, -}) { - final streamTheme = StreamTheme(brightness: brightness); - return Theme( - data: ThemeData( - brightness: brightness, - extensions: [streamTheme], - ), - child: Builder( - builder: (context) => Material( - color: StreamTheme.of(context).colorScheme.backgroundApp, - child: child, - ), - ), - ); -} diff --git a/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart b/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart index a5b297f1..a9ba97ea 100644 --- a/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart +++ b/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; @@ -11,6 +12,21 @@ Widget _withStreamTheme(Widget child) { ); } +// Wraps [body] in a try/finally that pins the platform for the duration +// of the test and clears the override before flutter_test's end-of-test +// `debugAssertAllFoundationVarsUnset` assertion runs. +Future _onPlatform( + TargetPlatform platform, + Future Function() body, +) async { + debugDefaultTargetPlatformOverride = platform; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + void main() { group('StreamSheetHeader auto-implied leading', () { testWidgets('does nothing when route cannot pop', (tester) async { @@ -24,14 +40,36 @@ void main() { expect(find.byType(StreamButton), findsNothing); }); - testWidgets('inserts back chevron on a regular pushed route', (tester) async { - await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); + testWidgets('inserts arrow-left on a regular pushed route on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); - expect(find.byType(StreamButton), findsOneWidget); - expect(find.byIcon(StreamIconData.chevronLeft), findsOneWidget); - expect(find.byIcon(StreamIconData.xmark), findsNothing); + expect(find.byType(StreamButton), findsOneWidget); + expect(find.byIcon(StreamIconData.arrowLeft), findsOneWidget); + expect(find.byIcon(StreamIconData.chevronLeft), findsNothing); + expect(find.byIcon(StreamIconData.xmark), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('inserts back chevron on a regular pushed route on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget(_withStreamTheme(const _LauncherScreen())); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(StreamButton), findsOneWidget); + expect(find.byIcon(StreamIconData.chevronLeft), findsOneWidget); + expect(find.byIcon(StreamIconData.arrowLeft), findsNothing); + expect(find.byIcon(StreamIconData.xmark), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } }); testWidgets('inserts cross icon on a fullscreen dialog', (tester) async { @@ -85,8 +123,8 @@ void main() { ); testWidgets( - 'inserts back chevron when a StreamSheetRoute covers another sheet', - (tester) async { + 'inserts back chevron when a StreamSheetRoute covers another sheet on iOS', + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -123,13 +161,13 @@ void main() { ), findsNothing, ); - }, + }), ); testWidgets( 'inserts cross at first nested route inside a StreamSheetRoute and ' - 'back chevron at deeper nested routes', - (tester) async { + 'back chevron at deeper nested routes on iOS', + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher(useNestedNavigation: true)), ); @@ -146,13 +184,13 @@ void main() { // Deeper nested route: back chevron. expect(find.byIcon(StreamIconData.chevronLeft), findsOneWidget); expect(find.byIcon(StreamIconData.xmark), findsNothing); - }, + }), ); testWidgets( 'inserts back chevron on the first nested route of a stacked sheet ' - '(stacked + nested combo)', - (tester) async { + '(stacked + nested combo) on iOS', + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher()), ); @@ -194,7 +232,7 @@ void main() { ), findsNothing, ); - }, + }), ); }); @@ -218,7 +256,7 @@ void main() { testWidgets( 'tapping the chevron on a stacked StreamSheetRoute pops only itself; ' 'parent sheet stays mounted', - (tester) async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -252,13 +290,13 @@ void main() { // Only the stacked sheet popped — the parent sheet is still mounted. expect(find.text('Stacked'), findsNothing); expect(find.text('Sheet'), findsOneWidget); - }, + }), ); testWidgets( 'tapping the chevron on a deeper nested route pops only that ' 'nested level; the sheet stays mounted', - (tester) async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher(useNestedNavigation: true)), ); @@ -280,13 +318,13 @@ void main() { expect(find.text('Deeper'), findsNothing); expect(find.text('Sheet'), findsOneWidget); expect(find.byIcon(StreamIconData.xmark), findsOneWidget); - }, + }), ); testWidgets( 'tapping the chevron on the first nested route of a stacked sheet ' 'pops the entire stacked sheet (not just the nested route)', - (tester) async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -319,7 +357,7 @@ void main() { // The whole stacked sheet popped — parent sheet still mounted. expect(find.text('Stacked'), findsNothing); expect(find.text('Sheet'), findsOneWidget); - }, + }), ); }); @@ -328,7 +366,7 @@ void main() { 'props.style > theme.style > token defaults (three-level merge)', (tester) async { const propsPadding = EdgeInsets.all(7); - const themeTitleStyle = TextStyle(fontSize: 99, color: Color(0xFF112233)); + const themeTitleStyle = TextStyle(fontSize: 18, color: Color(0xFF112233)); // Theme provides only `titleTextStyle`. Props provide only // `padding`. Other fields must fall through to token defaults. @@ -341,9 +379,6 @@ void main() { ), child: Scaffold( body: StreamSheetHeader( - // Skip the SafeArea wrap so the inner Padding is the - // first one under the header — makes the assertion - // below straightforward. primary: false, automaticallyImplyLeading: false, title: const Text('Title'), @@ -355,16 +390,15 @@ void main() { ), ); - // Props win for padding. - final padding = tester.widget( - find - .descendant( - of: find.byType(StreamSheetHeader), - matching: find.byType(Padding), - ) - .first, + // Props win for padding (the header passes its resolved padding + // through to the [StreamHeaderToolbar]'s `padding` property). + final toolbar = tester.widget( + find.descendant( + of: find.byType(StreamSheetHeader), + matching: find.byType(StreamHeaderToolbar), + ), ); - expect(padding.padding, equals(propsPadding)); + expect(toolbar.padding, equals(propsPadding)); // Theme wins for titleTextStyle (props didn't set it). final titleStyle = tester