From b3c2d9554f9fd3d63d1b96349e88cbe7be01f68a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Sun, 3 May 2026 21:55:01 +0200 Subject: [PATCH 1/8] fix(ui): preserve body's intrinsic height in StreamSheet drag-handle wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag-handle Stack used `StackFit.expand` + an inner `Align`, which gave the body tight max-height constraints and forced every sheet to fill the screen — even when the body opted into shrink-wrapping via `MainAxisSize.min`. Switch to default `StackFit.loose` and position the handle via the Stack's `alignment` instead of a child `Align`. The body now sizes naturally: shrink-wraps when it wants to (MainAxisSize.min), and still fills the screen when it uses `Expanded` / `MainAxisSize.max` from inside. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/sheet/stream_sheet.dart | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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 a4c01da..42cac40 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 @@ -953,15 +953,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], ); } From c0f651a30dcae993865ecfb94ff7a1722c24655f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Sun, 3 May 2026 23:49:58 +0200 Subject: [PATCH 2/8] fix(ui): cache sheet height during drag to avoid dirty-render-object Reading `context.size` from `_handleDragEnd` after a setState elsewhere in the tree marked the render object dirty mid-gesture throws: > Cannot get size from a render object that has been marked dirty for > layout. The drag-end handler ran via `goBallistic` from the scrollable's position; in between the last drag-update layout and end-of-gesture, a state-driven rebuild (an upload-progress setState in our case) marked the parent stack dirty, so the size getter blew up. Cache the sheet height in both gesture-detector and scrollable-driven paths during `_handleDragUpdate` (where context is reliably laid out), and reuse the cached value in `_handleDragEnd`. Falls through to the existing safe defaults (`0` velocity / `1` height) when no update has fired. All existing showStreamSheet tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/sheet/stream_sheet.dart | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) 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 42cac40..0f50936 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 @@ -1090,6 +1090,12 @@ class _StreamDragGestureDetectorState extends State<_StreamDragGestureDetector> _StreamSheetTransitionScope? _transitionScope; + // Cached sheet height captured during the last successful drag update. + // Used by [_handleDragEnd] instead of re-reading [context.size], which + // can throw if a setState elsewhere in the tree marked the render + // object dirty mid-gesture. + double? _lastSheetHeight; + @override void initState() { super.initState(); @@ -1138,6 +1144,7 @@ class _StreamDragGestureDetectorState extends State<_StreamDragGestureDetector> assert(_dragGestureController != null); final size = context.size; if (size == null || size.height <= 0) return; + _lastSheetHeight = size.height; _dragGestureController?.dragUpdate( details.primaryDelta!, sheetHeight: size.height, @@ -1149,8 +1156,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; + // Use the height cached from the last successful drag update — + // re-reading context.size here can throw when the render object has + // been marked dirty mid-gesture (e.g. by a setState in the body). + final sheetHeight = _lastSheetHeight; + final velocity = sheetHeight != null && sheetHeight > 0 ? details.velocity.pixelsPerSecond.dy / sheetHeight : 0.0; final isClosing = _dragGestureController?.dragEnd( velocity, @@ -1336,6 +1346,12 @@ class _StreamDraggableScrollableSheetState extends State<_StreamDraggableScrolla late final _StreamSheetScrollController _scrollController; _StreamDragGestureController? _dragGestureController; + // Cached sheet height captured during the last successful drag update. + // Used by [_handleDragEnd] instead of re-reading [context.size], which + // can throw if a setState elsewhere in the tree marked the render + // object dirty mid-gesture. + double? _lastSheetHeight; + @override void initState() { super.initState(); @@ -1378,6 +1394,7 @@ class _StreamDraggableScrollableSheetState extends State<_StreamDraggableScrolla if (dragController == null) return; final size = context.size; if (size == null || size.height <= 0) return; + _lastSheetHeight = size.height; dragController.dragUpdate( delta, sheetHeight: size.height, @@ -1390,8 +1407,12 @@ 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; + // Use the height cached from the last successful drag update — + // re-reading context.size here can throw when the render object + // has been marked dirty mid-gesture (e.g. by a setState in the + // sheet body). + final cached = _lastSheetHeight; + final sheetHeight = cached != null && 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. From 91a909aea1ba5aa5533349bc1efe7a8cecb78d4d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 4 May 2026 00:00:40 +0200 Subject: [PATCH 3/8] refactor(ui): drive StreamSheet drag math from a layout-driven extent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `context.size` reads in both drag-handler classes with a `_StreamSheetExtent` snapshotted by a `LayoutBuilder` in `StreamSheetRoute.buildPage`. Mirrors Flutter's `DraggableScrollableSheet` extent pattern, trimmed to just the height field we use: - `_StreamSheetExtent` is a plain value holder for `availableHeight`. - `StreamSheetRoute` owns one `_extent`. `buildPage` wraps the body in a `LayoutBuilder` that writes `_extent.availableHeight = constraints.maxHeight` on each layout pass — parent constraints are stable through layout, immune to the dirty-render-object propagation that happens when a body `setState` marks a `Stack(StackFit.loose)` parent dirty mid-gesture. - Both `_StreamDragGestureDetector` and `_StreamDraggableScrollableSheet` now take the extent as a constructor field. `_handleDragUpdate` and `_handleDragEnd` read `widget.extent.availableHeight` instead of `context.size`. The cached-height workaround in 409e33d is gone — we no longer need it because we never read the rendered size from the drag handlers. Existing showStreamSheet (11) and StreamSheetHeader (16) tests pass. Long-term, this opens the door to features the size-reading model couldn't easily support — multiple snap points, programmatic `animateTo(0.5)`, listenable size, etc. — by extending `_StreamSheetExtent` rather than retrofitting layout reads. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/sheet/stream_sheet.dart | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) 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 0f50936..a479c11 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, @@ -987,6 +1022,7 @@ class StreamSheetRoute extends PageRoute { isStacked: isStacked, topPadding: topPaddingFor(context), child: _StreamDragGestureDetector( + extent: _extent, enabledCallback: () => enableDrag, onStartPopGesture: _startPopGesture, onDragStart: onDragStart, @@ -1067,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, @@ -1074,6 +1111,7 @@ class _StreamDragGestureDetector extends StatefulWidget { this.onDragEnd, }); + final _StreamSheetExtent extent; final Widget child; final ValueGetter enabledCallback; final ValueGetter<_StreamDragGestureController> onStartPopGesture; @@ -1090,12 +1128,6 @@ class _StreamDragGestureDetectorState extends State<_StreamDragGestureDetector> _StreamSheetTransitionScope? _transitionScope; - // Cached sheet height captured during the last successful drag update. - // Used by [_handleDragEnd] instead of re-reading [context.size], which - // can throw if a setState elsewhere in the tree marked the render - // object dirty mid-gesture. - double? _lastSheetHeight; - @override void initState() { super.initState(); @@ -1142,12 +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; - _lastSheetHeight = size.height; + 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, ); @@ -1156,11 +1187,11 @@ class _StreamDragGestureDetectorState extends State<_StreamDragGestureDetector> void _handleDragEnd(DragEndDetails details) { assert(mounted); assert(_dragGestureController != null); - // Use the height cached from the last successful drag update — - // re-reading context.size here can throw when the render object has - // been marked dirty mid-gesture (e.g. by a setState in the body). - final sheetHeight = _lastSheetHeight; - final velocity = sheetHeight != null && sheetHeight > 0 ? details.velocity.pixelsPerSecond.dy / sheetHeight : 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, @@ -1323,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, @@ -1331,6 +1363,7 @@ class _StreamDraggableScrollableSheet extends StatefulWidget { required this.builder, }); + final _StreamSheetExtent extent; final ValueGetter enableDrag; final AnimationController popDragController; final NavigatorState navigator; @@ -1346,12 +1379,6 @@ class _StreamDraggableScrollableSheetState extends State<_StreamDraggableScrolla late final _StreamSheetScrollController _scrollController; _StreamDragGestureController? _dragGestureController; - // Cached sheet height captured during the last successful drag update. - // Used by [_handleDragEnd] instead of re-reading [context.size], which - // can throw if a setState elsewhere in the tree marked the render - // object dirty mid-gesture. - double? _lastSheetHeight; - @override void initState() { super.initState(); @@ -1392,12 +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; - _lastSheetHeight = size.height; + final sheetHeight = widget.extent.availableHeight; + if (sheetHeight <= 0) return; dragController.dragUpdate( delta, - sheetHeight: size.height, + sheetHeight: sheetHeight, stretchPixels: context.streamSpacing.xs, ); } @@ -1407,12 +1433,11 @@ class _StreamDraggableScrollableSheetState extends State<_StreamDraggableScrolla final dragController = _dragGestureController; if (dragController == null) return; _dragGestureController = null; - // Use the height cached from the last successful drag update — - // re-reading context.size here can throw when the render object - // has been marked dirty mid-gesture (e.g. by a setState in the - // sheet body). - final cached = _lastSheetHeight; - final sheetHeight = cached != null && cached > 0 ? cached : 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. From 92608908ce723259060057a7c66808bec15fc12e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 4 May 2026 00:42:36 +0200 Subject: [PATCH 4/8] =?UTF-8?q?fix(ui):=20StreamAppBar=20+=20sheet=20heade?= =?UTF-8?q?r=20=E2=80=94=20platform-aware=20leading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three drifts from the design system landed at once: - StreamAppBar background was unset, so Material's AppBar fell through to ColorScheme.surface and picked up scrolled-under tint. Lock to streamColorScheme.backgroundElevation1 (transparent surface tint) so the bar stays the design's flat white. Per-call backgroundColor still wins. - StreamAppBar's auto-implied back leading used Material's BackButton, which alternates between Icons.arrow_back (Android / web) and Icons.arrow_back_ios_new (iOS / macOS). Now mirror Material's logic in design-system icons: fullscreenDialog → streamIcons.xmark iOS / macOS → streamIcons.chevronLeft other platforms → streamIcons.arrowLeft automaticallyImplyLeading is set to false on the inner AppBar so it doesn't insert a duplicate. - StreamSheetHeader's "regular pushed page" branch hardcoded chevron; align it with the AppBar's platform switch (sheet branches stay on the chevron / cross convention because sheets are iOS-modal-style on every platform). Test changes: - Renamed the sheet header test to "inserts arrow-left on a regular pushed route on Android" and added a sibling iOS variant. Both use debugDefaultTargetPlatformOverride wrapped in try/finally so the framework's debug-vars-unset invariant still holds at end-of-test. No public API change. All 16 sheet header tests + 11 sheet tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/common/stream_app_bar.dart | 53 +++++++++++++++++-- .../header/stream_sheet_header.dart | 13 ++++- .../header/stream_sheet_header_test.dart | 37 ++++++++++--- 3 files changed, 91 insertions(+), 12 deletions(-) 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 index 969fc5e..cb14cd5 100644 --- 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 @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../factory/stream_component_factory.dart'; +import '../../theme/primitives/stream_colors.dart'; import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_theme_extensions.dart'; @@ -188,18 +189,62 @@ class DefaultStreamAppBar extends StatelessWidget { final theme = Theme.of(context); final streamTextTheme = context.streamTextTheme; final streamColorScheme = context.streamColorScheme; + final streamIcons = context.streamIcons; + + // Auto-imply a Stream-styled leading when the caller didn't pass one + // — mirror Material's AppBar logic but route through the design + // system's icons: + // + // * Inside a fullscreen dialog → cross (xmark), since the route + // presents modally rather than pushing onto a stack. + // * Otherwise, on platforms with iOS-style back navigation (iOS / + // macOS) → chevron-left. + // * Otherwise → arrow-left (the standard Material back affordance + // on Android / web / desktop platforms). + var leading = props.leading; + if (leading == null && props.automaticallyImplyLeading) { + final parentRoute = ModalRoute.of(context); + final canPop = parentRoute?.canPop ?? false; + if (canPop) { + final useCloseIcon = parentRoute is PageRoute && parentRoute.fullscreenDialog; + final tooltips = MaterialLocalizations.of(context); + final IconData icon; + final String tooltip; + if (useCloseIcon) { + icon = streamIcons.xmark; + tooltip = tooltips.closeButtonTooltip; + } else { + icon = switch (theme.platform) { + TargetPlatform.iOS || TargetPlatform.macOS => streamIcons.chevronLeft, + _ => streamIcons.arrowLeft, + }; + tooltip = tooltips.backButtonTooltip; + } + leading = IconButton( + icon: Icon(icon), + onPressed: () => Navigator.of(context).maybePop(), + tooltip: tooltip, + ); + } + } return AppBar( - automaticallyImplyLeading: props.automaticallyImplyLeading, + // Already auto-implied above; tell Material to keep its hands off + // the leading slot so it doesn't insert its own back button on top + // of ours. + automaticallyImplyLeading: false, 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, + // Lock the background to elevation-1 so Material's surface-tint + // / scrolled-under tint never slides the bar away from the design + // system's white. Per-call backgroundColor still wins. + backgroundColor: props.backgroundColor ?? streamColorScheme.backgroundElevation1, + surfaceTintColor: props.surfaceTintColor ?? StreamColors.transparent, centerTitle: props.centerTitle, - leading: props.leading, + leading: leading, leadingWidth: props.leadingWidth, titleSpacing: props.titleSpacing, actions: props.actions, 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 3669908..74e532a 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 @@ -278,7 +278,18 @@ class DefaultStreamSheetHeader extends StatelessWidget { } } else if (parentRoute != null && parentRoute.impliesAppBarDismissal) { final isRegularPage = parentRoute is PageRoute && !parentRoute.fullscreenDialog; - icon = isRegularPage ? icons.chevronLeft : icons.xmark; + if (isRegularPage) { + // Match the platform-aware leading [StreamAppBar] auto-implies + // for regular pushed pages — chevron on iOS-style platforms, + // arrow elsewhere. Sheet contexts above keep the chevron + // because they're iOS-modal-style on every platform. + icon = switch (Theme.of(context).platform) { + TargetPlatform.iOS || TargetPlatform.macOS => icons.chevronLeft, + _ => icons.arrowLeft, + }; + } else { + icon = icons.xmark; + } onPressed = Navigator.of(context).maybePop; } 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 a5b297f..86d76f9 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'; @@ -24,14 +25,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 { From faca4a293ce9816f1c67b4128469a086bc1784bc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 4 May 2026 08:12:05 +0200 Subject: [PATCH 5/8] feat(ui): redesign StreamAppBar with slots-based API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Material AppBar wrapper with a slots-based StreamAppBar (leading / title / subtitle / trailing) that follows the same factory + theme + style pattern as the rest of the design system. Auto-implied leading is platform-aware: cross on fullscreen dialogs, chevron on iOS / macOS, arrow-left elsewhere. Extracts StreamHeaderToolbar — a 3-slot layout primitive shared by StreamAppBar and StreamSheetHeader that keeps the title geometrically centred even when leading and trailing widths differ. Replaces the StreamVisibility-based 48×48 spacer hack in StreamSheetHeader. StreamSheetHeader changes: - Now implements PreferredSizeWidget. - Uses StreamHeaderToolbar for layout. - Sheet branches (stacked sheet, deeper nested route) now use the platform-aware back affordance instead of always chevron, matching StreamAppBar's regular-pushed-page behaviour. - Subtitle colour: textTertiary → textSecondary. Tests: new stream_app_bar_test.dart with auto-implied leading + style precedence cases; sheet header tests pinned to iOS via a small _onPlatform helper for chevron-expecting cases (flutter_test defaults to Android so the platform-aware sheet branches need explicit pinning). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/app/gallery_app.directories.g.dart | 17 + .../lib/components/header/stream_app_bar.dart | 341 +++++++++++++++++ .../header/stream_sheet_header.dart | 23 ++ packages/stream_core_flutter/CHANGELOG.md | 4 +- .../lib/src/components.dart | 3 +- .../src/components/common/stream_app_bar.dart | 265 ------------- .../src/components/header/stream_app_bar.dart | 351 ++++++++++++++++++ .../header/stream_header_toolbar.dart | 132 +++++++ .../header/stream_sheet_header.dart | 123 +++--- .../stream_core_flutter/lib/src/theme.dart | 1 + .../components/stream_app_bar_theme.dart | 185 +++++++++ .../stream_app_bar_theme.g.theme.dart | 212 +++++++++++ .../lib/src/theme/stream_theme.dart | 9 + .../lib/src/theme/stream_theme.g.theme.dart | 9 + .../src/theme/stream_theme_extensions.dart | 4 + .../header/stream_app_bar_test.dart | 192 ++++++++++ .../header/stream_sheet_header_test.dart | 67 ++-- 17 files changed, 1583 insertions(+), 355 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/header/stream_app_bar.dart delete mode 100644 packages/stream_core_flutter/lib/src/components/common/stream_app_bar.dart create mode 100644 packages/stream_core_flutter/lib/src/components/header/stream_app_bar.dart create mode 100644 packages/stream_core_flutter/lib/src/components/header/stream_header_toolbar.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_app_bar_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index 8d13c1b..a9a5b67 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 0000000..38e6b9c --- /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 e963821..8adfe61 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 315fb1e..c483488 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 3696cb6..6edf46a 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 cb14cd5..0000000 --- a/packages/stream_core_flutter/lib/src/components/common/stream_app_bar.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../factory/stream_component_factory.dart'; -import '../../theme/primitives/stream_colors.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; - final streamIcons = context.streamIcons; - - // Auto-imply a Stream-styled leading when the caller didn't pass one - // — mirror Material's AppBar logic but route through the design - // system's icons: - // - // * Inside a fullscreen dialog → cross (xmark), since the route - // presents modally rather than pushing onto a stack. - // * Otherwise, on platforms with iOS-style back navigation (iOS / - // macOS) → chevron-left. - // * Otherwise → arrow-left (the standard Material back affordance - // on Android / web / desktop platforms). - var leading = props.leading; - if (leading == null && props.automaticallyImplyLeading) { - final parentRoute = ModalRoute.of(context); - final canPop = parentRoute?.canPop ?? false; - if (canPop) { - final useCloseIcon = parentRoute is PageRoute && parentRoute.fullscreenDialog; - final tooltips = MaterialLocalizations.of(context); - final IconData icon; - final String tooltip; - if (useCloseIcon) { - icon = streamIcons.xmark; - tooltip = tooltips.closeButtonTooltip; - } else { - icon = switch (theme.platform) { - TargetPlatform.iOS || TargetPlatform.macOS => streamIcons.chevronLeft, - _ => streamIcons.arrowLeft, - }; - tooltip = tooltips.backButtonTooltip; - } - leading = IconButton( - icon: Icon(icon), - onPressed: () => Navigator.of(context).maybePop(), - tooltip: tooltip, - ); - } - } - - return AppBar( - // Already auto-implied above; tell Material to keep its hands off - // the leading slot so it doesn't insert its own back button on top - // of ours. - automaticallyImplyLeading: false, - toolbarTextStyle: theme.textTheme.bodyMedium, - titleTextStyle: props.titleTextStyle ?? streamTextTheme.headingSm, - systemOverlayStyle: theme.brightness == Brightness.dark ? SystemUiOverlayStyle.light : SystemUiOverlayStyle.dark, - elevation: props.elevation, - scrolledUnderElevation: props.scrolledUnderElevation, - // Lock the background to elevation-1 so Material's surface-tint - // / scrolled-under tint never slides the bar away from the design - // system's white. Per-call backgroundColor still wins. - backgroundColor: props.backgroundColor ?? streamColorScheme.backgroundElevation1, - surfaceTintColor: props.surfaceTintColor ?? StreamColors.transparent, - centerTitle: props.centerTitle, - leading: 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 0000000..bbff983 --- /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 0000000..30a6e5a --- /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 74e532a..0c0231e 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,29 +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; - if (isRegularPage) { - // Match the platform-aware leading [StreamAppBar] auto-implies - // for regular pushed pages — chevron on iOS-style platforms, - // arrow elsewhere. Sheet contexts above keep the chevron - // because they're iOS-modal-style on every platform. - icon = switch (Theme.of(context).platform) { - TargetPlatform.iOS || TargetPlatform.macOS => icons.chevronLeft, - _ => icons.arrowLeft, - }; - } else { - icon = 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; } @@ -322,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( @@ -350,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, ), ); @@ -406,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/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 148dc3e..fdaeffb 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 0000000..3134100 --- /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 0000000..5f0e722 --- /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 0b37059..ffd5eeb 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 a3f644a..fdc48da 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 4e357e4..97c6919 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/stream_app_bar_test.dart b/packages/stream_core_flutter/test/components/header/stream_app_bar_test.dart new file mode 100644 index 0000000..bc4fb3e --- /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_test.dart b/packages/stream_core_flutter/test/components/header/stream_sheet_header_test.dart index 86d76f9..6775a9c 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 @@ -12,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 { @@ -108,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) async => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -146,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) async => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher(useNestedNavigation: true)), ); @@ -169,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) async => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher()), ); @@ -217,7 +232,7 @@ void main() { ), findsNothing, ); - }, + }), ); }); @@ -241,7 +256,7 @@ void main() { testWidgets( 'tapping the chevron on a stacked StreamSheetRoute pops only itself; ' 'parent sheet stays mounted', - (tester) async { + (tester) async => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -275,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) async => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher(useNestedNavigation: true)), ); @@ -303,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) async => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -342,7 +357,7 @@ void main() { // The whole stacked sheet popped — parent sheet still mounted. expect(find.text('Stacked'), findsNothing); expect(find.text('Sheet'), findsOneWidget); - }, + }), ); }); @@ -351,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. @@ -364,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'), @@ -378,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 From 461ec8dc4ea6ae3c326b3aa6534449a619949d6d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 4 May 2026 08:14:35 +0200 Subject: [PATCH 6/8] refactor(tests): simplify test function signatures in StreamSheetHeader tests --- .../components/header/stream_sheet_header_test.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 6775a9c..a9ba97e 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 @@ -124,7 +124,7 @@ void main() { testWidgets( 'inserts back chevron when a StreamSheetRoute covers another sheet on iOS', - (tester) async => _onPlatform(TargetPlatform.iOS, () async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -167,7 +167,7 @@ void main() { testWidgets( 'inserts cross at first nested route inside a StreamSheetRoute and ' 'back chevron at deeper nested routes on iOS', - (tester) async => _onPlatform(TargetPlatform.iOS, () async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher(useNestedNavigation: true)), ); @@ -190,7 +190,7 @@ void main() { testWidgets( 'inserts back chevron on the first nested route of a stacked sheet ' '(stacked + nested combo) on iOS', - (tester) async => _onPlatform(TargetPlatform.iOS, () async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher()), ); @@ -256,7 +256,7 @@ void main() { testWidgets( 'tapping the chevron on a stacked StreamSheetRoute pops only itself; ' 'parent sheet stays mounted', - (tester) async => _onPlatform(TargetPlatform.iOS, () async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); @@ -296,7 +296,7 @@ void main() { testWidgets( 'tapping the chevron on a deeper nested route pops only that ' 'nested level; the sheet stays mounted', - (tester) async => _onPlatform(TargetPlatform.iOS, () async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget( _withStreamTheme(const _StreamSheetLauncher(useNestedNavigation: true)), ); @@ -324,7 +324,7 @@ void main() { 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 => _onPlatform(TargetPlatform.iOS, () async { + (tester) => _onPlatform(TargetPlatform.iOS, () async { await tester.pumpWidget(_withStreamTheme(const _StreamSheetLauncher())); await tester.tap(find.text('Open stream sheet')); await tester.pumpAndSettle(); From 3d52344b2976ba16ef82681e3a4ef2cc54c3eef1 Mon Sep 17 00:00:00 2001 From: xsahil03x <25670178+xsahil03x@users.noreply.github.com> Date: Mon, 4 May 2026 06:16:05 +0000 Subject: [PATCH 7/8] chore: Update Goldens --- .../ci/stream_sheet_header_dark_matrix.png | Bin 12281 -> 12073 bytes .../ci/stream_sheet_header_light_matrix.png | Bin 11331 -> 11134 bytes 2 files changed, 0 insertions(+), 0 deletions(-) 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 index 3aaf59927691965c064488556c00f00ceaf2f283..9f92dbc5b09ec211abf3554f6cc3668e8a4e01a6 100644 GIT binary patch literal 12073 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 lf{5>>xc+Yd0S1>Zc8>Uz7j< literal 12281 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 b={uZ)rhOU027f!x9oyKz{7TuS+rRz~l(9`- 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 index b5ad58c29e187c2152173bfdddc27ddb02505c79..d12d534be24ce2010aa865526ce0fe826522ca97 100644 GIT binary patch literal 11134 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 A*8l(j literal 11331 zcmdU#cT`i`x9>NiU_m*SBO>i6O-0}+NNqX^NvHvW1wBeThIRr35Dp?D zT?hdJ2r7n>fb&lR^ue`LNZo)SSqG%$MCg(` zg&uF*Wp}#^lf=wO2#KiwA!OE*&X=@dS}h&vm14h{W#Sx-3I4vKX8;*~CB{9b zf9dnx>dSr%C4RLec%-SUJ`}E>KWby2DzCO*XZjcmS089jXz1huW6AP6b_ZM_i$|yV zz$NmU*%Qufvwbgq=iEMb`7mc9bR^G!S%IK8yY)_Ru1Q&Yxxppt$p6KMU#TvBoQ)$^tQ26h3=T+}CLQU@(G*&0N{s?zmch-zsXMVTERGU6Q}X{9-HSW{ISnq3;OGf7tx|JTpbD;or~G5dXs zUsi3Gz}VQl(|NUB5VI*D5Dv%gi7X_?{KqSSLOP@nx(*NB`Ngi)t4WZ3`_Cu8~*Ha^_9x<)>Ur6H=RdV8p5U)fQD$9-$1q z+H&pqar*jF>C100Au;0W^9Q*n%#1iz5P8G-L4J~pw1-(Rk4-0H2G;B14|K@ybp42GaP#x? zmC4Z}sQlagYY^dYj^UnpUJgGHMgB5_P*X)LyS@^HLO;2$gSI4ZCTmt7Pq9^1$NlN! zBCcm}HaD87{eEh%edJ>ts&s1au`is}h&-Ya?p|bJe5CbO3l~gcXQcatwQm=FzxN>j z_S(f8k+H9L+Oe)7pnLsmrcN*~;7Y6!yCVcrx!$F4952h+gVuuI zT{_}OZ2W!;n7C35PTw*H=K|@_t4O@2|}A0Z)GwMNxTX|XYU{J)BD zJ2&mD-2O@**xyroX%@Zq(Cp7?u`VC@@}tAK^TI;TJB1#ow_yE?KyAeo$K{fg3E`)4 zVycda7{3!Ig}14PVl1a*siH}ch4HMZ$hVJlbX=}~DKL+j7Sw6)7S{!kT) z4Gp~R+Zspoxu@6`553jpxLC=)m^i+PvX{UOy(ZR(m~^b2^^z)2YV2Hk>X+`1!80q{ z^V&RS$A9{{tE=blDRU!95*+(iC||l)kz8I@J*a7EXw-QnFgqfCJgz`)`ul6Vcey9U^ zlpp2VdHBp#gTp*#mz$bgFj!6Or`+}I94OJGbTrn>pnDAr<^O|S_^Zb1!z0|B8C8@Aenj?T z*^d>mbicH;S6ouRPZ!MucG|%PtD5=Rpx4{k)d4qc8KnWbYYofBL z`PkitYM-lIfpW@Xh7mtjf_}YAj#&PT6^ir%!7(Xl4i+?ujvqybO?WaY#zzRo=$cho z{9P-?nhYAX^v44F;F-qF@3#(#sbd0>xzaQz@YWxt3HKCVE2wF7CtQduoECR7D%$Fz zc1%;!*^}JFXg0EJNgzR5^KWGr$n4;Y-?Pw%GEKI}=n3_k77Te@CYz}h;LWV6DUdhL zSbRgJWuoV>Ga(j+=(coh>#H1T;HaT$CSW7x=8bm;;TALedSy=?UYl*mP(Y=>A0&v- zI;N?qqbA!_j|4H~UBBjC&a<4s_^%-t>U2{|n0Z+VDakEUqeC-->4z3@O4wxO$$XqD z#IJu6t{uNR`g!(e9>G~Q1KEV#BsyIyZS$iYx`z_DJlf$Ut-WP1GB99T|5n>&S4l-T zNNYOypP5S6Z)l@=*`bULcE$FQxrO7lMEaHwGhIF$>AyLrFA;_GlEuM?UXF*0;XsnJ zDlYxiOO_c9bB3>d6(KIMOHh*i*kmiTScnTFN_#&f*K*o%aQV~>xQ$J8T z8XQDVi87NdC@8?MbacKeDmt#b{c=1imOrr%6Gg?ab6I|e-H)SbwRJ3c=v~xlzW%Q! zZGp_AAziTqvDq8Sfz4_chFGOgOg!CY*6&hbGM~M^t z>hk*eAeh!FNIBk3BvA*7O|=*k+p+84pqVZ!saX?vS{ zsMqQC*X%d-n17#Tr8UYnqm`N8xVpF(lhe>CnoW*aNaH@pcved99ahY1?2)--{$8g;;YxF#~fGyE`)CsX4QaY;87%4k_L-{!C{YJa8 zRD9igmZgz(L9f4h9+~v3$hfEp=u0!TlJ8JeK;*fa32R0rzX0}o2F2f;5j~osLCwxi zIF{36OB!yxb=CUFWXYo-WPC{%A z#$bzFuM2&L91gvRIbk9<+M_92roO%)lR%H~yr|KUX}{q9P?q-PI`iO5J>>LI0!`zy z|G{402l>2y0aZF6C5|?h)jMG^-0Wv$d2Mw&Cd!iWy!dv(UOV77)HawqP2j6Ii!WiBC#+s@2`vB@LT%|Vl-X!ujevzw8&AogbR(^eB;@uc zOEg1+UP>f~1zo(@UlX(7E<)+r3CA3&8aMduFS_@?SD)z@dq$FSdrw>v3l zK`iPJV`e6#^cALvbxIn)1evu(E$s^sy9WE1iEheEaEh>Fj5(QtIHJ5wuK`wn3z!W) z8Fv0Xde-lZ+MYDxEpOF=0?Cc|HD60Bt1eqgcE z;$r!h7;A$mg<32_7)I#jk5}V@dCD+@0KE<-TQ0W>7Q^sGdy{JPR$IWRoebkyB>kkrcJ%cKzc6a;| z7opsvY-%^Ncy3+v&Htq3x+Cs1XcCwSME272y9!_}Gq(~B45d4PuqbG-QKcVV*aBW8 zc;ZY@XSXNt^|zrNh|YZrqn2kITgnXjzTYbF@O!&kDKn{uTFpF$&7K9`b5OaMy~J_T zoVM~J&f{mdVoK1of6l7rFL~rPHz50fFgas$LJ1i?Jq!Z2)Unv(enTO*MbED_XyQxP*H03jNaM_?6jK=)iZ1eP?uHu==p|-*Kr1#0K%&UIB zcYwIn`4E?3R2N!1b&MI%MeAd#a;#tub58BUdXlQpM0tiZeao!ZUWcv0oXI|#Zf658 z)U#oZpYxW>Litv$H!9`PlbD4HK+yJIY|)r~+ct0}^CD@l%+mEsS*VPN+qWv8zk1wG z6_mv0S04AEy$Cm_uD>6Wv9Y#4r^aKJ#f$(;N(-DqFiP!uw|5%X*!T(>a>A6~)LIGE z9FlI1j(fllF6PBGK9O(JgpDE=kkKX&O#8HQYYh6`mH$m8r!#WJ{;9RMTd zh{U<{!DRQGn~0bj-+mIEs4W27G%Yf zntXK^EpVDd#n%7X%^FVPS40P~Z}}7Bw&RA3-JBvQ2-(P%K?NJcBNi?rY;|ve3KCQD zD**)hwCz`GH$-qPJaxzX5Uyuxf=ni+o_7I}OkQ4%ImEx>qu~tS$dE}G4UJ&D2wL@P z8%5n=IPlMp25)u1l)$$7bsF&^&fjEbu=rpklk20uk(FU_-d@h6`AZp~l{Z~G&!Tag z-MztB^@-mBFyJ3V-6UtWD5u5R*RivK(x*?WH1sYqJfy@n_=$D-l(_PsbL(IHB;@N} zc1xo_v)2?Z)zNMP7i&w%gsHQxdzp+gh2r#ZBXl6XHeDC9u zqi3L!=U)DeYoEn1yjV`{1AZQRE#v;DLG3(FJE4U&&*{19Eagc_bu`Xth#mVlKiRf0 zndsvZ@*@{njv_Btd;Ag0cW@F-=;GW_xO8E1{?CRzLY@-Zi&&xuNP|C-gFO5To5$to zzJIU>`Xsbh97dLW1BO1g;4`lPwzakNINqowIIW6eKUuZQZgAl_)wuYEBZEdv^PQWw zmJju3IH5F3Ln9+nPl&aDT=TTFw1fxxYI)F(mkm1-TV}j0ttHf9)6hYv)A(zaGvt(WM{CXzJA#I-jh+>vjdofbmS5 zq6yM-s)(4WR)n#?*LKm4Qj;6FkQ_$CNsv+YjJ1GT%x-Ax;R!Jj(kgK=JryH+{-2ag zNbF2rUbr2DUSvcW9w(&G$y7teWM*{<{7hqH3-Y^eH7}$iIESHRMA zp6@(zBTHj@EQ&}wB*|tS*b=L8G^Rw%M-_i5RXhSv(N(~;R_Pd%^3Ew%KL`+RVecOm z&+iuf^PxV2J@euilVUuG3gAm0q3P zRBuk;uR9&mnV`H{`x7M=^myUdvaul@p9I`sh+tjPfM<6(*n>pbo zRYQH9B0b9fBUY70TBKIfq@2QhK+K^01*c#^G6iV=E;rPPP7iz9XDVXO%vD%y^v=2G=j0i zc}_sEo4|d`IN&C}V-X0nL{Y`Cl{oQQ=c!21d-9p>EcOhY zLC#!5blwcTi-1Wi!4CjxXk<%fX{GvkVd79XwJ1G9)e^yX z?RD4m-n9{WlJ43j1T;T z;;G-D;Z`Ba;&kmdY~}(H&unMAI5Jb6h_~ZIubPth8#cmLlaJ=Lb3El=4X{_evyzf} z+GNkW-ziP%W%wr#$=s90B}hExZvO{lbPfL9MZD>rA_Ad<5>`(eUc*zzaS~3dsbTZM z9LmTZ;s8b<8JeA5Zl)PXsDFN8!bhttsY=90WL8 z3Z6W#CTDg^Z0j1+mX)hs-Pd0%YJ4c&P;MxsuVU4XDcPrSetoqy+Qcr#8#R9k6q5TB zDQneMMbk{A?GuiqR)s5Zw&iYHEjmZ)msNFFH+7zo#Tbh}mb+@vVy} zwi(Vy$-K5bvAV>jV=@gL)yuuspc=-|VXlqT8##u70$XO$FF9-yNtW7A#IQ6BL$f#;2@qi(?lh2x z|K*@A_3Pb+J+lZjGyEKWfk15M?G1`cfcsoDs)e8f-#BLhh&9{!NR7!d>IO}c9BOf^ zzz1Oqz4g?ME{Q$Uuuup{-pA>3RaJ#Fk%V!eKkf}kl(-Asj2s!dBC}4Yzd=E`;C?|) zl%uD8gy6m^nFnMWNI+gD4IL6Q|AT9mQ)R^uhyAwxp>Oo|ADXOAp&(!A?Y*Xxxu7QE zlIQNvSRObi^I;);`)D3{a*7BDx^_uG#UQc3&(M)wuT-?VrsgLW3_3rRwEihqSS!$9 z*SMsHLzDoC#W>l|5JB=k`xC>)%6?ejAS6Ix8Qa4^M#2d_PVv z^yOm)gjIqSWn^d+-aTUrCT&uEn}I>Smc@kW&kNSlq3par_ou3=rvL)`dc<*hT4jFq z+96?$RDi!VMR@zwwl~E`W=CIj?V#8N71Z`iN*s4XZ{P<#YXCnApT)1NOfLd#?5iHu z*7o~Yglw?{IU*dDDT-QaCBjkl>PoQ0f5O-zB&U!Zm(e!284`%&S*xyR!5ByVrblC@ z063b~zP7!guqDVJSx9>nT_WCW>h>y9^4L@hpX_ey5EG=Kqm3ae`zXP=W!g=1#8wW{{*-fq$Wb*5jf5%|IJ$g}Mu23FsbI7K5 z@gXC$E0&nXDiLOcQNmnM$u4CTg+)b^pe9*fZQB+I?Qq#Ii;bUoTV~s?FxuE(w{UwE z4iMpvqgw({@7)nSYoI}oDU`(tYxgW|-HL3V_;Q_5z|NUEMl>F!gxg8J>+4KVxF;*X z;&}@8a064)u~e=znO1~6b@6wGPAcflN#u)msA00pp3V#E<7YpKI)E8!Hq9hvOwZ45 zY<^;iRe!@o#y;$O9=AE~`HO7~;v?7EkZ;TAvn%s=nvPQh9shyB2 zZ?w)Fmkb$8tkFd5GDKUAn#J*&;D>ajq2R3E+fPK~2aY`7G2)h_eP=!cd$qjw9@}|E z2EY9@+q`7pee#fMcwiBzrQi2*ul(Vbs^LrQ_Mv&*cE|KlCmFRp zP}--|=n{~#o^{Hlc*%O?Kfy>&W491gmJJSZ&UPYS|irsXZy zNFpUMMLxNac$OV8emlVGC%inb_F1)^B%ka)dNGS_J5MdvHb=aGv(m_jC9VeL^2yV% zJEgV8zsL?mdaModaE<(`XAL(sp;7TpI4wdae3!f^Ezb%;SD=VXMX9@Q@KB;I$K6YG{ z=hyKQQFTB(T9V=Xl5X36E_h(9}UijO#R~&!MQu_3XPzC?T3a%Ui9D&^&`7>?jscg z4wT}aGu(Hv&-P7GnNw>^27jc#HS%z;^^(EH>$ZVN%8_C9z2PUDB_FqcN}`3cEP_Y_k> zZ;%T;deSQO7d>+9fgl0&3{WQ%kO}Xcf`Im4?7yB7cpE%CKW!E8&o%VC|J&oo+yo;y z7Y{G*MT3-Ph@195BDV@Ikp2dF6SIX(j|coZMX0eb8zkVFL$#mw8=~#&WI~Ai0Itgc0_4iP+IhA=CUE|*^XW{S zRa|yrov&NUIe*fRHF{otSzQg#bG%nUHzrw!1YX+QGqB!qK z?~0EwU|@z`AZ@qGJfyUjpWOZIKKrK63-3xt)P)X-fJ(}8_@;hNhVkmcxpt0b)#puM zZu&E-W)pJug#FrIA&dVeNi-}a?`rOOK>d3W+-9 zFSn3^s~m3jwy#=wDrI$%{OKPsM}7~?2WZ7`)LOYmC{q6!i=B0#-@cC4Ld9)t{-80P z{tD~UWvNR+cAV`T2t9s0%+JYjMGL6NXZDrkI_C!NGUVy?uigm)PF<{004u1i^<5)5 zd`g30sWm*=diPd?pIQQ-GQpCcX}u)Ue-g-SsxJG~K;L*>1l@x1a&(G(NhGq+yJ_%m zz?HzEE?0UHrTr#_qqyfOcZ!cV-{46j3Zcr7ilH5^lxK)2*7U-K{(54mMF)`QMg8@8 z9zs+6{0AprY#}e+l{!(PZyIXIFzpLaE}=&mIG{I|0*FBxRmwEpEH$M~nIV8FVq*Cw z)~3rbAlWd5UmyN}kU55fm2gFG<%~sa;E@ruyA*WcCs5v=iSC3Ix^xx-f2f zEn*8pOhPK1lN_!G)D4Tll0o$fxlH!oseGs2SpL)Q25dCteeHeez?zf@*+!V0)A`!LfDq`QqGP zsU3NA7>&}B^o=~~CAzUz_|PDEsj4-B<8-8+)sfxK zE-Q$`WMC?qHI*LZqIm$@TR`%CT)@u8#-Y8X#mmytDnSD1eQ?qU{1q41#hego|8n1G zG>Q+lj};_=S{ol37k4tDEG}|nNZw?EIh;7XzCgbL_?n0dF(|^AvDqvcU!Og-d_M&g z2d@JP*Lj<&V!0{QYg9 z;{ICdv%;l23CJw_z?cB1GfUah0@&S<(*5!^37`X50-WP$(y From 3d4787fc110f3a8e25bc83947d79aaaefbaa9c63 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 4 May 2026 11:26:15 +0200 Subject: [PATCH 8/8] test(ui): remove StreamSheetHeader golden tests --- .../ci/stream_sheet_header_dark_matrix.png | Bin 12073 -> 0 bytes .../ci/stream_sheet_header_light_matrix.png | Bin 11134 -> 0 bytes .../stream_sheet_header_golden_test.dart | 143 ------------------ 3 files changed, 143 deletions(-) delete mode 100644 packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_dark_matrix.png delete mode 100644 packages/stream_core_flutter/test/components/header/goldens/ci/stream_sheet_header_light_matrix.png delete mode 100644 packages/stream_core_flutter/test/components/header/stream_sheet_header_golden_test.dart 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 9f92dbc5b09ec211abf3554f6cc3668e8a4e01a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12073 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 lf{5>>xc+Yd0S1>Zc8>Uz7j< 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 d12d534be24ce2010aa865526ce0fe826522ca97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11134 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 A*8l(j 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 b341582..0000000 --- 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, - ), - ), - ); -}