diff --git a/apps/design_system_gallery/lib/components/badge/stream_error_badge.dart b/apps/design_system_gallery/lib/components/badge/stream_error_badge.dart index e4ea39fa..bf566b20 100644 --- a/apps/design_system_gallery/lib/components/badge/stream_error_badge.dart +++ b/apps/design_system_gallery/lib/components/badge/stream_error_badge.dart @@ -21,8 +21,22 @@ Widget buildStreamErrorBadgePlayground(BuildContext context) { description: 'The diameter of the badge.', ); + final style = context.knobs.object.dropdown( + label: 'Style', + options: StreamErrorBadgeStyle.values, + initialOption: StreamErrorBadgeStyle.error, + labelBuilder: (option) => option.name.toUpperCase(), + description: 'The severity the badge conveys.', + ); + + final showBorder = context.knobs.boolean( + label: 'Show Border', + initialValue: true, + description: 'Whether to show a border around the badge.', + ); + return Center( - child: StreamErrorBadge(size: size), + child: StreamErrorBadge(size: size, style: style, showBorder: showBorder), ); } @@ -44,10 +58,12 @@ Widget buildStreamErrorBadgeShowcase(BuildContext context) { style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), child: SingleChildScrollView( padding: EdgeInsets.all(spacing.lg), - child: const Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SizeVariantsSection(), + const _SizeVariantsSection(), + SizedBox(height: spacing.xl), + const _StyleVariantsSection(), ], ), ), @@ -154,6 +170,109 @@ class _SizeDemo extends StatelessWidget { } } +// ============================================================================= +// Style Variants Section +// ============================================================================= + +class _StyleVariantsSection extends StatelessWidget { + const _StyleVariantsSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'STYLE VARIANTS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Error and warning, each with the border on and off', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final style in StreamErrorBadgeStyle.values) + for (final showBorder in [true, false]) + Padding( + padding: EdgeInsetsDirectional.only(end: spacing.xl), + child: _StyleDemo(style: style, showBorder: showBorder), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _StyleDemo extends StatelessWidget { + const _StyleDemo({required this.style, required this.showBorder}); + + final StreamErrorBadgeStyle style; + final bool showBorder; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 48, + height: 48, + child: Center( + child: StreamErrorBadge(style: style, showBorder: showBorder), + ), + ), + SizedBox(height: spacing.sm), + Text( + style.name.toUpperCase(), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + showBorder ? 'border' : 'no border', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + ); + } +} + // ============================================================================= // Shared Widgets // ============================================================================= diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index f0fcf3f7..082c92e7 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -3,6 +3,10 @@ ### ✨ Features - Added the `lowBandwidthFill` icon. +- Added `StreamErrorBadge.style`, taking a `StreamErrorBadgeStyle` — `.error` + (the default) or `.warning` — and `StreamErrorBadge.showBorder`. +- Added `StreamErrorBadgeTheme` and `StreamErrorBadgeThemeData`, carrying a + background and foreground color per style. ## 0.5.1 diff --git a/packages/stream_core_flutter/lib/core.dart b/packages/stream_core_flutter/lib/core.dart index fa8e0dee..f47fb479 100644 --- a/packages/stream_core_flutter/lib/core.dart +++ b/packages/stream_core_flutter/lib/core.dart @@ -77,6 +77,7 @@ export 'src/theme/components/stream_context_menu_action_theme.dart'; export 'src/theme/components/stream_context_menu_theme.dart'; export 'src/theme/components/stream_emoji_button_theme.dart'; export 'src/theme/components/stream_emoji_chip_theme.dart'; +export 'src/theme/components/stream_error_badge_theme.dart'; export 'src/theme/components/stream_list_tile_theme.dart'; export 'src/theme/components/stream_media_viewer_theme.dart'; export 'src/theme/components/stream_online_indicator_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_error_badge.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_error_badge.dart index b3088fc9..6d114479 100644 --- a/packages/stream_core_flutter/lib/src/components/badge/stream_error_badge.dart +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_error_badge.dart @@ -1,37 +1,18 @@ import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_error_badge_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; import '../../theme/primitives/stream_icons.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/stream_theme_extensions.dart'; -/// Predefined sizes for [StreamErrorBadge]. -/// -/// Each size corresponds to a specific diameter and icon size in logical pixels. -enum StreamErrorBadgeSize { - /// Medium badge (24px diameter, 20px icon). - md(24, 20), - - /// Small badge (20px diameter, 16px icon). - sm(20, 16), - - /// Extra-small badge (16px diameter, 12px icon). - xs(16, 12); - - const StreamErrorBadgeSize(this.value, this.iconSize); - - /// The diameter of the badge in logical pixels. - final double value; - - /// The icon size for this badge size. - final double iconSize; -} - -/// A circular error badge that displays an exclamation mark icon. +/// A circular badge that displays an exclamation mark icon. /// /// [StreamErrorBadge] is used to indicate a failed operation, such as a /// message that could not be sent. It renders as a fixed-size circle with -/// an error-colored background and an exclamation mark icon. +/// a background colored by [StreamErrorBadgeStyle] and an exclamation mark +/// icon. /// /// {@tool snippet} /// @@ -51,9 +32,29 @@ enum StreamErrorBadgeSize { /// ``` /// {@end-tool} /// +/// {@tool snippet} +/// +/// Warning variant without a border, as a call control button uses it: +/// +/// ```dart +/// StreamErrorBadge( +/// style: StreamErrorBadgeStyle.warning, +/// showBorder: false, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamErrorBadge] uses [StreamErrorBadgeThemeData] for default styling. +/// Colors are determined by the current [StreamColorScheme]. +/// /// See also: /// /// * [StreamErrorBadgeSize], the available size variants. +/// * [StreamErrorBadgeStyle], the available style variants. +/// * [StreamErrorBadgeThemeData], for customizing appearance. +/// * [StreamErrorBadgeTheme], for overriding theme in a subtree. /// * [StreamRetryBadge], a badge for indicating retryable actions. /// * [StreamBadgeNotification], a badge for displaying notification counts. class StreamErrorBadge extends StatelessWidget { @@ -61,7 +62,9 @@ class StreamErrorBadge extends StatelessWidget { StreamErrorBadge({ super.key, StreamErrorBadgeSize? size, - }) : props = .new(size: size); + StreamErrorBadgeStyle? style, + bool showBorder = true, + }) : props = .new(size: size, style: style, showBorder: showBorder); /// The properties that configure this error badge. final StreamErrorBadgeProps props; @@ -85,18 +88,37 @@ class StreamErrorBadge extends StatelessWidget { /// * [DefaultStreamErrorBadge], the default implementation. class StreamErrorBadgeProps { /// Creates properties for an error badge. - const StreamErrorBadgeProps({this.size}); + const StreamErrorBadgeProps({ + this.size, + this.style, + this.showBorder = true, + }); /// The size of the badge. /// - /// If null, defaults to [StreamErrorBadgeSize.sm]. + /// If null, uses [StreamErrorBadgeThemeData.size], or falls back to + /// [StreamErrorBadgeSize.sm]. final StreamErrorBadgeSize? size; + + /// The severity the badge conveys. + /// + /// If null, defaults to [StreamErrorBadgeStyle.error]. + final StreamErrorBadgeStyle? style; + + /// Whether a border is drawn around the badge. + /// + /// The border style is determined by [StreamErrorBadgeThemeData.border]. It + /// is drawn outside the badge's [size], so it separates the badge from + /// whatever it overlaps without changing the badge's layout size. Defaults + /// to true. + final bool showBorder; } /// The default implementation of [StreamErrorBadge]. /// /// Renders a circular badge with an exclamation mark icon. Styling is -/// resolved from the current [StreamColorScheme] and [StreamIcons]. +/// resolved from [StreamErrorBadgeThemeData], falling back to the current +/// [StreamColorScheme] and [StreamIcons]. /// /// See also: /// @@ -112,27 +134,76 @@ class DefaultStreamErrorBadge extends StatelessWidget { @override Widget build(BuildContext context) { final icons = context.streamIcons; - final colorScheme = context.streamColorScheme; - final effectiveSize = props.size ?? StreamErrorBadgeSize.sm; + final theme = context.streamErrorBadgeTheme; + final defaults = _StreamErrorBadgeThemeDefaults(context); - final border = Border.all( - width: 2, - color: colorScheme.borderOnInverse, - strokeAlign: BorderSide.strokeAlignOutside, - ); + final effectiveSize = props.size ?? theme.size ?? defaults.size; + final effectiveStyle = props.style ?? StreamErrorBadgeStyle.error; + final effectiveBorder = props.showBorder ? theme.border ?? defaults.border : null; + + final effectiveBackgroundColor = _resolveBackgroundColor(effectiveStyle, theme, defaults); + final effectiveForegroundColor = _resolveForegroundColor(effectiveStyle, theme, defaults); return AnimatedContainer( width: effectiveSize.value, height: effectiveSize.value, clipBehavior: Clip.antiAlias, duration: kThemeChangeDuration, - decoration: BoxDecoration(shape: BoxShape.circle, color: colorScheme.accentError), - foregroundDecoration: BoxDecoration(shape: BoxShape.circle, border: border), + decoration: BoxDecoration(shape: BoxShape.circle, color: effectiveBackgroundColor), + foregroundDecoration: BoxDecoration(shape: BoxShape.circle, border: effectiveBorder), child: IconTheme( - data: .new(size: effectiveSize.iconSize, color: colorScheme.textOnAccent), + data: .new(size: effectiveSize.iconSize, color: effectiveForegroundColor), child: Center(child: Icon(icons.exclamationMarkFill)), ), ); } + + Color _resolveBackgroundColor( + StreamErrorBadgeStyle style, + StreamErrorBadgeThemeData theme, + _StreamErrorBadgeThemeDefaults defaults, + ) => switch (style) { + .error => theme.errorBackgroundColor ?? defaults.errorBackgroundColor, + .warning => theme.warningBackgroundColor ?? defaults.warningBackgroundColor, + }; + + Color _resolveForegroundColor( + StreamErrorBadgeStyle style, + StreamErrorBadgeThemeData theme, + _StreamErrorBadgeThemeDefaults defaults, + ) => switch (style) { + .error => theme.errorForegroundColor ?? defaults.errorForegroundColor, + .warning => theme.warningForegroundColor ?? defaults.warningForegroundColor, + }; +} + +class _StreamErrorBadgeThemeDefaults extends StreamErrorBadgeThemeData { + _StreamErrorBadgeThemeDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + + @override + StreamErrorBadgeSize get size => .sm; + + @override + Color get errorBackgroundColor => _colorScheme.accentError; + + @override + Color get errorForegroundColor => _colorScheme.textOnAccent; + + @override + Color get warningBackgroundColor => _colorScheme.accentWarning; + + @override + Color get warningForegroundColor => StreamColors.black; + + @override + BoxBorder get border => Border.all( + width: 2, + color: _colorScheme.borderOnInverse, + strokeAlign: BorderSide.strokeAlignOutside, + ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_error_badge_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_error_badge_theme.dart new file mode 100644 index 00000000..bfa0421c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_error_badge_theme.dart @@ -0,0 +1,170 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_error_badge_theme.g.theme.dart'; + +/// Predefined sizes for [StreamErrorBadge]. +/// +/// Each size corresponds to a specific diameter and icon size in logical +/// pixels. +/// +/// See also: +/// +/// * [StreamErrorBadge], which uses these size variants. +/// * [StreamErrorBadgeThemeData.size], for setting a global default. +enum StreamErrorBadgeSize { + /// Medium badge (24px diameter, 20px icon). + md(24, 20), + + /// Small badge (20px diameter, 16px icon). + sm(20, 16), + + /// Extra-small badge (16px diameter, 12px icon). + xs(16, 12); + + const StreamErrorBadgeSize(this.value, this.iconSize); + + /// The diameter of the badge in logical pixels. + final double value; + + /// The icon size for this badge size. + final double iconSize; +} + +/// The severity a [StreamErrorBadge] conveys. +/// +/// Determines which background and icon color the badge applies. +/// +/// See also: +/// +/// * [StreamErrorBadge], which uses these style variants. +enum StreamErrorBadgeStyle { + /// Error style — an error-colored background with an on-accent icon. + /// + /// The default. Reads as a failure that needs the user's attention. + error, + + /// Warning style — a warning-colored background with a black icon. + /// + /// For a cautionary state rather than an outright failure, and for + /// surfaces where the error style would not separate from what sits + /// underneath — a call control button, or arbitrary video. + /// + /// The icon is pinned to black rather than to a mode-aware text color, + /// because the warning background does not invert between light and dark. + warning, +} + +/// Applies an error badge theme to descendant widgets. +/// +/// Wrap a subtree with [StreamErrorBadgeTheme] to override error badge +/// styling. Access the merged theme using [BuildContext.streamErrorBadgeTheme]. +/// +/// See also: +/// +/// * [StreamErrorBadgeThemeData], which describes the theme. +/// * [StreamErrorBadge], the widget affected by this theme. +class StreamErrorBadgeTheme extends InheritedTheme { + /// Creates an error badge theme. + const StreamErrorBadgeTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The error badge theme data for descendant widgets. + final StreamErrorBadgeThemeData data; + + /// Returns the merged [StreamErrorBadgeThemeData] from local and global + /// themes. + static StreamErrorBadgeThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).errorBadgeTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamErrorBadgeTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamErrorBadgeTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamErrorBadge] widgets. +/// +/// Organizes badge colors by [StreamErrorBadgeStyle], so the warning style can +/// be styled without touching the error one. +/// +/// {@tool snippet} +/// +/// Customize badge appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// errorBadgeTheme: StreamErrorBadgeThemeData( +/// warningBackgroundColor: Colors.amber, +/// warningForegroundColor: Colors.black, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamErrorBadge], the widget that uses this theme data. +/// * [StreamErrorBadgeTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamErrorBadgeThemeData with _$StreamErrorBadgeThemeData { + /// Creates an error badge theme with optional style overrides. + const StreamErrorBadgeThemeData({ + this.size, + this.errorBackgroundColor, + this.errorForegroundColor, + this.warningBackgroundColor, + this.warningForegroundColor, + this.border, + }); + + /// The default size for error badges. + /// + /// Falls back to [StreamErrorBadgeSize.sm]. + final StreamErrorBadgeSize? size; + + /// The fill color of badges of the [StreamErrorBadgeStyle.error] style. + /// + /// Defaults to [StreamColorScheme.accentError]. + final Color? errorBackgroundColor; + + /// The icon color of badges of the [StreamErrorBadgeStyle.error] style. + /// + /// Defaults to [StreamColorScheme.textOnAccent]. + final Color? errorForegroundColor; + + /// The fill color of badges of the [StreamErrorBadgeStyle.warning] style. + /// + /// Defaults to [StreamColorScheme.accentWarning]. + final Color? warningBackgroundColor; + + /// The icon color of badges of the [StreamErrorBadgeStyle.warning] style. + /// + /// Defaults to black rather than to a mode-aware text color, because the + /// warning background does not invert between light and dark. + final Color? warningForegroundColor; + + /// The border drawn around the badge. + /// + /// Applied when [StreamErrorBadge.showBorder] is true. Allows customization + /// of both border color and width. Shared by both styles. + final BoxBorder? border; + + /// Linearly interpolate between two [StreamErrorBadgeThemeData]. + static StreamErrorBadgeThemeData? lerp( + StreamErrorBadgeThemeData? a, + StreamErrorBadgeThemeData? b, + double t, + ) => _$StreamErrorBadgeThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_error_badge_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_error_badge_theme.g.theme.dart new file mode 100644 index 00000000..60c1bfa4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_error_badge_theme.g.theme.dart @@ -0,0 +1,136 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_error_badge_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamErrorBadgeThemeData { + bool get canMerge => true; + + static StreamErrorBadgeThemeData? lerp( + StreamErrorBadgeThemeData? a, + StreamErrorBadgeThemeData? 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 StreamErrorBadgeThemeData( + size: t < 0.5 ? a.size : b.size, + errorBackgroundColor: Color.lerp( + a.errorBackgroundColor, + b.errorBackgroundColor, + t, + ), + errorForegroundColor: Color.lerp( + a.errorForegroundColor, + b.errorForegroundColor, + t, + ), + warningBackgroundColor: Color.lerp( + a.warningBackgroundColor, + b.warningBackgroundColor, + t, + ), + warningForegroundColor: Color.lerp( + a.warningForegroundColor, + b.warningForegroundColor, + t, + ), + border: BoxBorder.lerp(a.border, b.border, t), + ); + } + + StreamErrorBadgeThemeData copyWith({ + StreamErrorBadgeSize? size, + Color? errorBackgroundColor, + Color? errorForegroundColor, + Color? warningBackgroundColor, + Color? warningForegroundColor, + BoxBorder? border, + }) { + final _this = (this as StreamErrorBadgeThemeData); + + return StreamErrorBadgeThemeData( + size: size ?? _this.size, + errorBackgroundColor: errorBackgroundColor ?? _this.errorBackgroundColor, + errorForegroundColor: errorForegroundColor ?? _this.errorForegroundColor, + warningBackgroundColor: + warningBackgroundColor ?? _this.warningBackgroundColor, + warningForegroundColor: + warningForegroundColor ?? _this.warningForegroundColor, + border: border ?? _this.border, + ); + } + + StreamErrorBadgeThemeData merge(StreamErrorBadgeThemeData? other) { + final _this = (this as StreamErrorBadgeThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + errorBackgroundColor: other.errorBackgroundColor, + errorForegroundColor: other.errorForegroundColor, + warningBackgroundColor: other.warningBackgroundColor, + warningForegroundColor: other.warningForegroundColor, + border: other.border, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamErrorBadgeThemeData); + final _other = (other as StreamErrorBadgeThemeData); + + return _other.size == _this.size && + _other.errorBackgroundColor == _this.errorBackgroundColor && + _other.errorForegroundColor == _this.errorForegroundColor && + _other.warningBackgroundColor == _this.warningBackgroundColor && + _other.warningForegroundColor == _this.warningForegroundColor && + _other.border == _this.border; + } + + @override + int get hashCode { + final _this = (this as StreamErrorBadgeThemeData); + + return Object.hash( + runtimeType, + _this.size, + _this.errorBackgroundColor, + _this.errorForegroundColor, + _this.warningBackgroundColor, + _this.warningForegroundColor, + _this.border, + ); + } +} 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 ae3b1757..6fbbea2a 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -16,6 +16,7 @@ import 'components/stream_context_menu_action_theme.dart'; import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; +import 'components/stream_error_badge_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; import 'components/stream_media_viewer_theme.dart'; @@ -133,6 +134,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamContextMenuActionThemeData? contextMenuActionTheme, StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, + StreamErrorBadgeThemeData? errorBadgeTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, StreamMediaViewerThemeData? mediaViewerTheme, @@ -195,6 +197,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { contextMenuActionTheme ??= const StreamContextMenuActionThemeData(); emojiButtonTheme ??= const StreamEmojiButtonThemeData(); emojiChipTheme ??= const StreamEmojiChipThemeData(); + errorBadgeTheme ??= const StreamErrorBadgeThemeData(); jumpToUnreadButtonTheme ??= const StreamJumpToUnreadButtonThemeData(); listTileTheme ??= const StreamListTileThemeData(); mediaViewerTheme ??= const StreamMediaViewerThemeData(); @@ -245,6 +248,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { contextMenuActionTheme: contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, + errorBadgeTheme: errorBadgeTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, mediaViewerTheme: mediaViewerTheme, @@ -309,6 +313,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.contextMenuActionTheme, required this.emojiButtonTheme, required this.emojiChipTheme, + required this.errorBadgeTheme, required this.jumpToUnreadButtonTheme, required this.listTileTheme, required this.mediaViewerTheme, @@ -448,6 +453,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The emoji chip theme for this theme. final StreamEmojiChipThemeData emojiChipTheme; + /// The error badge theme for this theme. + final StreamErrorBadgeThemeData errorBadgeTheme; + /// The jump-to-unread button theme for this theme. final StreamJumpToUnreadButtonThemeData jumpToUnreadButtonTheme; @@ -567,6 +575,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { contextMenuActionTheme: contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, + errorBadgeTheme: errorBadgeTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, mediaViewerTheme: mediaViewerTheme, 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 41dbcae2..fccde8d6 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 @@ -36,6 +36,7 @@ mixin _$StreamTheme on ThemeExtension { StreamContextMenuActionThemeData? contextMenuActionTheme, StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, + StreamErrorBadgeThemeData? errorBadgeTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, StreamMediaViewerThemeData? mediaViewerTheme, @@ -96,6 +97,7 @@ mixin _$StreamTheme on ThemeExtension { contextMenuActionTheme ?? _this.contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, emojiChipTheme: emojiChipTheme ?? _this.emojiChipTheme, + errorBadgeTheme: errorBadgeTheme ?? _this.errorBadgeTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme ?? _this.jumpToUnreadButtonTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, @@ -232,6 +234,11 @@ mixin _$StreamTheme on ThemeExtension { other.emojiChipTheme, t, )!, + errorBadgeTheme: StreamErrorBadgeThemeData.lerp( + _this.errorBadgeTheme, + other.errorBadgeTheme, + t, + )!, jumpToUnreadButtonTheme: StreamJumpToUnreadButtonThemeData.lerp( _this.jumpToUnreadButtonTheme, other.jumpToUnreadButtonTheme, @@ -399,6 +406,7 @@ mixin _$StreamTheme on ThemeExtension { _other.contextMenuActionTheme == _this.contextMenuActionTheme && _other.emojiButtonTheme == _this.emojiButtonTheme && _other.emojiChipTheme == _this.emojiChipTheme && + _other.errorBadgeTheme == _this.errorBadgeTheme && _other.jumpToUnreadButtonTheme == _this.jumpToUnreadButtonTheme && _other.listTileTheme == _this.listTileTheme && _other.mediaViewerTheme == _this.mediaViewerTheme && @@ -462,6 +470,7 @@ mixin _$StreamTheme on ThemeExtension { _this.contextMenuActionTheme, _this.emojiButtonTheme, _this.emojiChipTheme, + _this.errorBadgeTheme, _this.jumpToUnreadButtonTheme, _this.listTileTheme, _this.mediaViewerTheme, 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 d75227dd..106d735c 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 @@ -14,6 +14,7 @@ import 'components/stream_context_menu_action_theme.dart'; import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; +import 'components/stream_error_badge_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; import 'components/stream_media_viewer_theme.dart'; @@ -142,6 +143,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamEmojiChipThemeData] from the nearest ancestor. StreamEmojiChipThemeData get streamEmojiChipTheme => StreamEmojiChipTheme.of(this); + /// Returns the [StreamErrorBadgeThemeData] from the nearest ancestor. + StreamErrorBadgeThemeData get streamErrorBadgeTheme => StreamErrorBadgeTheme.of(this); + /// Returns the [StreamJumpToUnreadButtonThemeData] from the nearest ancestor. StreamJumpToUnreadButtonThemeData get streamJumpToUnreadButtonTheme => StreamJumpToUnreadButtonTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png new file mode 100644 index 00000000..20879917 Binary files /dev/null and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png differ diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png new file mode 100644 index 00000000..687cd4cd Binary files /dev/null and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png new file mode 100644 index 00000000..52dda521 Binary files /dev/null and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/badge/stream_error_badge_golden_test.dart b/packages/stream_core_flutter/test/components/badge/stream_error_badge_golden_test.dart new file mode 100644 index 00000000..9871da75 --- /dev/null +++ b/packages/stream_core_flutter/test/components/badge/stream_error_badge_golden_test.dart @@ -0,0 +1,102 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + group('StreamErrorBadge Golden Tests', () { + goldenTest( + 'renders light theme style and size matrix', + fileName: 'stream_error_badge_light_matrix', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 100), + children: [ + for (final style in StreamErrorBadgeStyle.values) + for (final size in StreamErrorBadgeSize.values) + GoldenTestScenario( + name: '${style.name}_${size.name}', + child: _buildInTheme( + StreamErrorBadge(style: style, size: size), + ), + ), + ], + ), + ); + + goldenTest( + 'renders dark theme style and size matrix', + fileName: 'stream_error_badge_dark_matrix', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 100), + children: [ + for (final style in StreamErrorBadgeStyle.values) + for (final size in StreamErrorBadgeSize.values) + GoldenTestScenario( + name: '${style.name}_${size.name}', + child: _buildInTheme( + StreamErrorBadge(style: style, size: size), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + + // The border's job is to separate the badge from what it overlaps, and its + // color tracks the app background — so it is only visible over something + // else. These scenarios overlay a contrasting swatch to show it. + goldenTest( + 'renders the border toggle over a contrasting surface', + fileName: 'stream_error_badge_border_toggle', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 100), + children: [ + for (final brightness in Brightness.values) + for (final style in StreamErrorBadgeStyle.values) + for (final showBorder in [true, false]) + GoldenTestScenario( + name: + '${brightness.name}_${style.name}_' + '${showBorder ? 'border' : 'no_border'}', + child: _buildInTheme( + StreamErrorBadge(style: style, showBorder: showBorder), + brightness: brightness, + overContrastingSurface: true, + ), + ), + ], + ), + ); + }); +} + +Widget _buildInTheme( + Widget child, { + Brightness brightness = Brightness.light, + bool overContrastingSurface = false, +}) { + final streamTheme = StreamTheme(brightness: brightness); + return Theme( + data: ThemeData( + brightness: brightness, + extensions: [streamTheme], + ), + child: Builder( + builder: (context) => Material( + color: StreamTheme.of(context).colorScheme.backgroundApp, + child: Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: switch (overContrastingSurface) { + true => ColoredBox( + color: StreamTheme.of(context).colorScheme.accentNeutral, + child: Padding(padding: const EdgeInsets.all(6), child: child), + ), + false => child, + }, + ), + ), + ), + ), + ); +} diff --git a/packages/stream_core_flutter/test/components/badge/stream_error_badge_test.dart b/packages/stream_core_flutter/test/components/badge/stream_error_badge_test.dart new file mode 100644 index 00000000..5e262e23 --- /dev/null +++ b/packages/stream_core_flutter/test/components/badge/stream_error_badge_test.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + testWidgets('StreamErrorBadge draws a border by default', (tester) async { + await tester.pumpWidget(_wrap(StreamErrorBadge())); + + final border = _borderOf(tester)!; + expect(border.top.color, _colorSchemeOf(tester).borderOnInverse); + expect(border.top.width, 2); + expect(border.top.strokeAlign, BorderSide.strokeAlignOutside); + }); + + testWidgets('StreamErrorBadge draws no border when showBorder is false', (tester) async { + await tester.pumpWidget(_wrap(StreamErrorBadge(showBorder: false))); + + expect(_borderOf(tester), isNull); + }); + + testWidgets('StreamErrorBadge keeps its layout size when the border is dropped', (tester) async { + const size = StreamErrorBadgeSize.sm; + + await tester.pumpWidget(_wrap(StreamErrorBadge(size: size))); + final withBorder = tester.getSize(find.byType(StreamErrorBadge)); + + await tester.pumpWidget(_wrap(StreamErrorBadge(size: size, showBorder: false))); + final withoutBorder = tester.getSize(find.byType(StreamErrorBadge)); + + expect(withBorder, Size.square(size.value)); + expect(withoutBorder, withBorder); + }); + + testWidgets('StreamErrorBadge uses the error colors by default', (tester) async { + await tester.pumpWidget(_wrap(StreamErrorBadge())); + + final colorScheme = _colorSchemeOf(tester); + expect(_backgroundColorOf(tester), colorScheme.accentError); + expect(_iconColorOf(tester), colorScheme.textOnAccent); + }); + + testWidgets('StreamErrorBadge uses the warning colors for the warning style', (tester) async { + await tester.pumpWidget(_wrap(StreamErrorBadge(style: StreamErrorBadgeStyle.warning))); + + expect(_backgroundColorOf(tester), _colorSchemeOf(tester).accentWarning); + expect(_iconColorOf(tester), StreamColors.black); + }); + + testWidgets('StreamErrorBadgeTheme overrides the resolved style colors', (tester) async { + const themeData = StreamErrorBadgeThemeData( + errorBackgroundColor: Color(0xFF111111), + errorForegroundColor: Color(0xFF222222), + warningBackgroundColor: Color(0xFF333333), + warningForegroundColor: Color(0xFF444444), + ); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme(data: themeData, child: StreamErrorBadge()), + ), + ); + + expect(_backgroundColorOf(tester), themeData.errorBackgroundColor); + expect(_iconColorOf(tester), themeData.errorForegroundColor); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme( + data: themeData, + child: StreamErrorBadge(style: StreamErrorBadgeStyle.warning), + ), + ), + ); + + expect(_backgroundColorOf(tester), themeData.warningBackgroundColor); + expect(_iconColorOf(tester), themeData.warningForegroundColor); + }); + + testWidgets('StreamErrorBadgeTheme leaves the other style untouched', (tester) async { + const themeData = StreamErrorBadgeThemeData(warningBackgroundColor: Color(0xFF333333)); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme(data: themeData, child: StreamErrorBadge()), + ), + ); + + final colorScheme = _colorSchemeOf(tester); + expect(_backgroundColorOf(tester), colorScheme.accentError); + expect(_iconColorOf(tester), colorScheme.textOnAccent); + }); + + testWidgets('StreamErrorBadgeTheme falls back per property', (tester) async { + // Only the background is overridden — the icon color must still resolve. + const themeData = StreamErrorBadgeThemeData(warningBackgroundColor: Color(0xFF333333)); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme( + data: themeData, + child: StreamErrorBadge(style: StreamErrorBadgeStyle.warning), + ), + ), + ); + + expect(_backgroundColorOf(tester), const Color(0xFF333333)); + expect(_iconColorOf(tester), StreamColors.black); + }); + + testWidgets('StreamErrorBadgeTheme overrides the border and default size', (tester) async { + final themeData = StreamErrorBadgeThemeData( + size: StreamErrorBadgeSize.md, + border: Border.all(width: 4, color: const Color(0xFF555555)), + ); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme(data: themeData, child: StreamErrorBadge()), + ), + ); + + expect(tester.getSize(find.byType(StreamErrorBadge)), Size.square(StreamErrorBadgeSize.md.value)); + expect(_borderOf(tester)!.top.width, 4); + expect(_borderOf(tester)!.top.color, const Color(0xFF555555)); + }); + + testWidgets('StreamErrorBadge.size wins over the theme default', (tester) async { + const themeData = StreamErrorBadgeThemeData(size: StreamErrorBadgeSize.md); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme( + data: themeData, + child: StreamErrorBadge(size: StreamErrorBadgeSize.xs), + ), + ), + ); + + expect(tester.getSize(find.byType(StreamErrorBadge)), Size.square(StreamErrorBadgeSize.xs.value)); + }); + + testWidgets('StreamErrorBadgeTheme border is ignored when showBorder is false', (tester) async { + final themeData = StreamErrorBadgeThemeData( + border: Border.all(width: 4, color: const Color(0xFF555555)), + ); + + await tester.pumpWidget( + _wrap( + StreamErrorBadgeTheme( + data: themeData, + child: StreamErrorBadge(showBorder: false), + ), + ), + ); + + expect(_borderOf(tester), isNull); + }); + + testWidgets('StreamErrorBadge pins the warning icon to black in both brightnesses', (tester) async { + for (final brightness in Brightness.values) { + await tester.pumpWidget( + _wrap( + StreamErrorBadge(style: StreamErrorBadgeStyle.warning), + brightness: brightness, + ), + ); + + expect(_iconColorOf(tester), StreamColors.black, reason: 'brightness: ${brightness.name}'); + } + }); +} + +Widget _wrap(Widget child, {Brightness brightness = Brightness.light}) { + return MaterialApp( + theme: ThemeData( + brightness: brightness, + extensions: [StreamTheme(brightness: brightness)], + ), + home: Scaffold(body: Center(child: child)), + ); +} + +StreamColorScheme _colorSchemeOf(WidgetTester tester) { + final context = tester.element(find.byType(StreamErrorBadge)); + return StreamTheme.of(context).colorScheme; +} + +// The badge paints its border into `foregroundDecoration` with +// `strokeAlignOutside`, so it separates the badge without growing it. +BoxBorder? _borderOf(WidgetTester tester) { + final container = tester.widget(find.byType(AnimatedContainer)); + return (container.foregroundDecoration! as BoxDecoration).border; +} + +Color? _backgroundColorOf(WidgetTester tester) { + final container = tester.widget(find.byType(AnimatedContainer)); + return (container.decoration! as BoxDecoration).color; +} + +Color? _iconColorOf(WidgetTester tester) { + return tester.widget(find.byType(Icon)).color ?? IconTheme.of(tester.element(find.byType(Icon))).color; +}