diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aabf9c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Flutter monorepo managed with **Melos** containing: +- `packages/stream_core` — Pure Dart SDK (WebSocket, HTTP, models, utilities) +- `packages/stream_core_flutter` — Flutter UI component library with a full design system +- `apps/design_system_gallery` — Widgetbook-based interactive component showcase + +## Common Commands + +All commands use Melos and should be run from the repo root. + +```bash +# Setup +melos bootstrap + +# Linting & formatting +melos run lint:all # analyze + format check +melos run analyze +melos run format +melos run format:verify # check only, no changes + +# Testing +melos run test:all # all tests with coverage +melos run test:dart # stream_core only +melos run test:flutter # stream_core_flutter only + +# Golden tests +melos run update:goldens # regenerate golden images + +# Code generation (run after model/theme changes) +melos run generate:all +melos run generate:icons # regenerate icon font from SVGs +melos run gen-l10n # regenerate localizations +``` + +**Line width:** 120 characters (set in `analysis_options.yaml`). + +## Design + +UI components are designed in **Figma**. When implementing or modifying components, use the **Figma MCP** to inspect designs directly — check spacing, colors, typography, and component structure from the source rather than guessing. + +## Architecture + +### Theme System (`stream_core_flutter/lib/src/theme/`) + +Uses `theme_extensions_builder` to generate Material 3 theme extensions. The hierarchy is: + +1. **Primitives** — raw design tokens: colors, typography, spacing, radius, icons +2. **Semantics** — semantic mappings (e.g., `primaryColor`, `bodyText`) +3. **Component themes** — per-widget theme classes (50+ components), defined in `theme/components/` +4. **Tokens** — light/dark concrete values in `theme/tokens/` + +Generated files have `.g.theme.dart` extension. After modifying `.theme.dart` files, run `melos run generate:flutter`. + +### Component Structure (`stream_core_flutter/lib/src/components/`) + +Components are organized by category: `avatar/`, `buttons/`, `badge/`, `list/`, `message_composer/`, `emoji/`, `context_menu/`, `controls/`, `common/`, `accessories/`. + +Each component typically has: +- A widget file +- A theme file in `theme/components/` +- A golden test in `test/components//` +- A Widgetbook use-case in `apps/design_system_gallery/` + +### stream_core Package + +Pure Dart. Key modules: +- `src/ws/` — WebSocket client with reconnect/backoff logic (RxDart-based) +- `src/api/` — Dio HTTP client with interceptors +- `src/attachment/` — File upload and CDN client +- `src/query/` — Query builders and filter models +- `src/logger/` — Structured logging +- `src/user/` — User models and token management + +### Golden Testing + +Golden tests use **Alchemist** (`^0.13.0`). Goldens are stored under: +- `test/components//goldens/ci/` — for CI +- `test/components//goldens/macos/` — for local macOS development + +Golden tests are tagged with `golden` in `dart_test.yaml`. Run `melos run update:goldens` to regenerate after visual changes. + +### Code Generation + +- **json_serializable** — model serialization (`.g.dart` files) +- **build_runner** — orchestrates all generation +- **theme_extensions_builder** — generates theme extension classes (`.g.theme.dart`) +- **widgetbook_generator** — auto-generates Widgetbook entries + +After any model or theme annotation changes, run the appropriate generate command before running tests. 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 4f5c90f..8d683e8 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 @@ -24,6 +24,8 @@ import 'package:design_system_gallery/components/avatar/stream_avatar_stack.dart as _design_system_gallery_components_avatar_stream_avatar_stack; import 'package:design_system_gallery/components/badge/stream_badge_count.dart' as _design_system_gallery_components_badge_stream_badge_count; +import 'package:design_system_gallery/components/badge/stream_badge_notification.dart' + as _design_system_gallery_components_badge_stream_badge_notification; import 'package:design_system_gallery/components/badge/stream_online_indicator.dart' as _design_system_gallery_components_badge_stream_online_indicator; import 'package:design_system_gallery/components/buttons/button.dart' @@ -305,6 +307,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamBadgeNotification', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_badge_stream_badge_notification + .buildStreamBadgeNotificationPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_badge_stream_badge_notification + .buildStreamBadgeNotificationShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamOnlineIndicator', useCases: [ diff --git a/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart index 05c67dc..84aad6d 100644 --- a/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart +++ b/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart @@ -123,7 +123,8 @@ class _SizeCard extends StatelessWidget { StreamAvatarSize.sm => 'Chat list items, notifications', StreamAvatarSize.md => 'Message bubbles, comments', StreamAvatarSize.lg => 'Profile headers, user cards', - StreamAvatarSize.xl => 'Hero sections, large profile displays', + StreamAvatarSize.xl => 'Channel list items, conversation lists', + StreamAvatarSize.xxl => 'Hero sections, large profile displays', }; } diff --git a/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart index edfd1dc..5efe6b2 100644 --- a/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart +++ b/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart @@ -128,7 +128,8 @@ class _SizeCard extends StatelessWidget { String _getUsage(StreamAvatarGroupSize size) { return switch (size) { StreamAvatarGroupSize.lg => 'Channel list items, compact group displays', - StreamAvatarGroupSize.xl => 'Channel headers, prominent group displays', + StreamAvatarGroupSize.xl => 'Channel list items, standard group displays', + StreamAvatarGroupSize.xxl => 'Channel headers, prominent group displays', }; } diff --git a/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart b/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart new file mode 100644 index 0000000..8e64506 --- /dev/null +++ b/apps/design_system_gallery/lib/components/badge/stream_badge_notification.dart @@ -0,0 +1,400 @@ +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: StreamBadgeNotification, + path: '[Components]/Badge', +) +Widget buildStreamBadgeNotificationPlayground(BuildContext context) { + final label = context.knobs.string( + label: 'Label', + initialValue: '1', + description: 'The text to display in the badge.', + ); + + final type = context.knobs.object.dropdown( + label: 'Type', + options: StreamBadgeNotificationType.values, + initialOption: StreamBadgeNotificationType.primary, + labelBuilder: (option) => option.name[0].toUpperCase() + option.name.substring(1), + description: 'The visual type of the badge.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamBadgeNotificationSize.values, + initialOption: StreamBadgeNotificationSize.sm, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'The size of the badge.', + ); + + return Center( + child: StreamBadgeNotification( + label: label, + type: type, + size: size, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamBadgeNotification, + path: '[Components]/Badge', +) +Widget buildStreamBadgeNotificationShowcase(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: [ + const _TypeVariantsSection(), + SizedBox(height: spacing.xl), + const _SizeVariantsSection(), + SizedBox(height: spacing.xl), + const _CountVariantsSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Type Variants Section +// ============================================================================= + +class _TypeVariantsSection extends StatelessWidget { + const _TypeVariantsSection(); + + @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: 'TYPE 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( + 'Badge types determine background color', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final type in StreamBadgeNotificationType.values) ...[ + _TypeDemo(type: type), + if (type != StreamBadgeNotificationType.values.last) SizedBox(width: spacing.xl), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +class _TypeDemo extends StatelessWidget { + const _TypeDemo({required this.type}); + + final StreamBadgeNotificationType type; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 48, + height: 32, + child: Center( + child: StreamBadgeNotification( + label: '1', + type: type, + ), + ), + ), + SizedBox(height: spacing.sm), + Text( + type.name[0].toUpperCase() + type.name.substring(1), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Size Variants Section +// ============================================================================= + +class _SizeVariantsSection extends StatelessWidget { + const _SizeVariantsSection(); + + @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: 'SIZE 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( + 'Two sizes for different contexts', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final size in StreamBadgeNotificationSize.values) ...[ + _SizeDemo(size: size), + if (size != StreamBadgeNotificationSize.values.last) SizedBox(width: spacing.xl), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +class _SizeDemo extends StatelessWidget { + const _SizeDemo({required this.size}); + + final StreamBadgeNotificationSize size; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 48, + height: 32, + child: Center( + child: StreamBadgeNotification( + label: '5', + size: size, + ), + ), + ), + SizedBox(height: spacing.sm), + Text( + size.name.toUpperCase(), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + '${size.value.toInt()}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Count Variants Section +// ============================================================================= + +class _CountVariantsSection extends StatelessWidget { + const _CountVariantsSection(); + + @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: 'COUNT 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( + 'Badge adapts width based on count', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.md, + runSpacing: spacing.md, + children: const [ + _CountDemo(count: 1), + _CountDemo(count: 9), + _CountDemo(count: 25), + _CountDemo(count: 99), + _CountDemo(count: 100), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _CountDemo extends StatelessWidget { + const _CountDemo({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + final displayText = count > 99 ? '99+' : '$count'; + + return Column( + children: [ + StreamBadgeNotification(label: displayText), + SizedBox(height: spacing.xs), + Text( + displayText, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textOnAccent, + letterSpacing: 1, + fontSize: 9, + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index fd62b4c..c8515f7 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -5,6 +5,7 @@ export 'components/avatar/stream_avatar.dart' hide DefaultStreamAvatar; export 'components/avatar/stream_avatar_group.dart' hide DefaultStreamAvatarGroup; export 'components/avatar/stream_avatar_stack.dart' hide DefaultStreamAvatarStack; export 'components/badge/stream_badge_count.dart' hide DefaultStreamBadgeCount; +export 'components/badge/stream_badge_notification.dart' hide DefaultStreamBadgeNotification; export 'components/badge/stream_media_badge.dart'; export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineIndicator; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart index b42ba06..7539958 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart @@ -242,7 +242,8 @@ class DefaultStreamAvatar extends StatelessWidget { .xs => textTheme.metadataEmphasis, .sm || .md => textTheme.captionEmphasis, .lg => textTheme.bodyEmphasis, - .xl => textTheme.headingLg, + .xl => textTheme.headingMd, + .xxl => textTheme.headingLg, }; // Returns the appropriate icon size for the given avatar size. @@ -253,7 +254,8 @@ class DefaultStreamAvatar extends StatelessWidget { .sm => 12, .md => 16, .lg => 20, - .xl => 32, + .xl => 24, + .xxl => 32, }; } diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart index 97c4190..bc1d4a0 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart @@ -18,8 +18,11 @@ enum StreamAvatarGroupSize { /// Large avatar group (40px diameter). lg(40), - /// Extra large avatar group (64px diameter). - xl(64) + /// Extra large avatar group (48px diameter). + xl(48), + + /// Extra-extra large avatar group (80px diameter). + xxl(80) ; /// Constructs a [StreamAvatarGroupSize] with the given diameter. @@ -359,7 +362,8 @@ class DefaultStreamAvatarGroup extends StatelessWidget { StreamAvatarGroupSize size, ) => switch (size) { .lg => StreamAvatarSize.sm, - .xl => StreamAvatarSize.lg, + .xl => StreamAvatarSize.md, + .xxl => StreamAvatarSize.lg, }; // Returns the appropriate badge count size for the given group size. @@ -368,5 +372,6 @@ class DefaultStreamAvatarGroup extends StatelessWidget { ) => switch (size) { .lg => StreamBadgeCountSize.sm, .xl => StreamBadgeCountSize.md, + .xxl => StreamBadgeCountSize.md, }; } diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart new file mode 100644 index 0000000..e88092d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_notification.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_badge_notification_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'; + +/// A notification badge for displaying counts in colored pill shapes. +/// +/// [StreamBadgeNotification] displays a count label in a colored pill-shaped +/// badge with a border. It's used in channel list items and other places to +/// indicate unread messages or pending notifications. +/// +/// Unlike [StreamBadgeCount], which uses neutral colors, this badge uses +/// prominent colored backgrounds (primary, error, neutral) to draw attention. +/// +/// The badge has three visual types controlled by +/// [StreamBadgeNotificationType]: +/// +/// * [StreamBadgeNotificationType.primary] — Brand accent background. +/// * [StreamBadgeNotificationType.error] — Error/red background. +/// * [StreamBadgeNotificationType.neutral] — Muted gray background. +/// +/// {@tool snippet} +/// +/// Basic usage with unread count: +/// +/// ```dart +/// StreamBadgeNotification(label: '3') +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Error variant: +/// +/// ```dart +/// StreamBadgeNotification( +/// label: '!', +/// type: StreamBadgeNotificationType.error, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamBadgeNotification] uses [StreamBadgeNotificationThemeData] for +/// default styling. Colors are determined by the current [StreamColorScheme]. +/// +/// See also: +/// +/// * [StreamBadgeNotificationSize], the available size variants. +/// * [StreamBadgeNotificationType], the available style variants. +/// * [StreamBadgeNotificationThemeData], for customizing appearance. +/// * [StreamBadgeNotificationTheme], for overriding theme in a subtree. +/// * [StreamBadgeCount], a neutral count badge without colored backgrounds. +class StreamBadgeNotification extends StatelessWidget { + /// Creates a badge notification indicator. + StreamBadgeNotification({ + super.key, + StreamBadgeNotificationType? type, + StreamBadgeNotificationSize? size, + required String label, + }) : props = StreamBadgeNotificationProps( + type: type, + size: size, + label: label, + ); + + /// The properties that configure this badge notification. + final StreamBadgeNotificationProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).badgeNotification; + if (builder != null) return builder(context, props); + return DefaultStreamBadgeNotification(props: props); + } +} + +/// Properties for configuring a [StreamBadgeNotification]. +/// +/// This class holds all the configuration options for a badge notification, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamBadgeNotification], which uses these properties. +/// * [DefaultStreamBadgeNotification], the default implementation. +class StreamBadgeNotificationProps { + /// Creates properties for a badge notification. + const StreamBadgeNotificationProps({ + this.type, + this.size, + required this.label, + }); + + /// The visual type determining the badge background color. + /// + /// If null, defaults to [StreamBadgeNotificationType.primary]. + final StreamBadgeNotificationType? type; + + /// The size of the badge. + /// + /// If null, uses [StreamBadgeNotificationThemeData.size], or falls back to + /// [StreamBadgeNotificationSize.sm]. + final StreamBadgeNotificationSize? size; + + /// The text label to display in the badge. + /// + /// Typically a numeric count (e.g., "5") or an overflow indicator + /// (e.g., "99+"). + final String label; +} + +/// The default implementation of [StreamBadgeNotification]. +/// +/// This widget renders the badge notification with theming support. +/// It's used as the default factory implementation in +/// [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamBadgeNotification], the public API widget. +/// * [StreamBadgeNotificationProps], which configures this widget. +class DefaultStreamBadgeNotification extends StatelessWidget { + /// Creates a default badge notification with the given [props]. + const DefaultStreamBadgeNotification({super.key, required this.props}); + + /// The properties that configure this badge notification. + final StreamBadgeNotificationProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + + final theme = context.streamBadgeNotificationTheme; + final defaults = _StreamBadgeNotificationThemeDefaults(context); + + final effectiveSize = props.size ?? theme.size ?? defaults.size; + final effectiveType = props.type ?? StreamBadgeNotificationType.primary; + final effectiveTextColor = theme.textColor ?? defaults.textColor; + final effectiveBorderColor = theme.borderColor ?? defaults.borderColor; + final effectiveBackgroundColor = _resolveBackgroundColor( + effectiveType, + theme, + defaults, + ); + + final padding = _paddingForSize(effectiveSize, spacing); + final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveTextColor); + + return IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + minWidth: effectiveSize.value, + minHeight: effectiveSize.value, + ), + padding: padding, + alignment: Alignment.center, + decoration: ShapeDecoration( + color: effectiveBackgroundColor, + shape: StadiumBorder( + side: BorderSide( + color: effectiveBorderColor, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + ), + child: DefaultTextStyle( + style: textStyle, + child: Text(props.label), + ), + ), + ); + } + + Color _resolveBackgroundColor( + StreamBadgeNotificationType type, + StreamBadgeNotificationThemeData theme, + _StreamBadgeNotificationThemeDefaults defaults, + ) => switch (type) { + StreamBadgeNotificationType.primary => theme.primaryBackgroundColor ?? defaults.primaryBackgroundColor, + StreamBadgeNotificationType.error => theme.errorBackgroundColor ?? defaults.errorBackgroundColor, + StreamBadgeNotificationType.neutral => theme.neutralBackgroundColor ?? defaults.neutralBackgroundColor, + }; + + TextStyle _textStyleForSize( + StreamBadgeNotificationSize size, + StreamTextTheme textTheme, + ) => switch (size) { + .xs => textTheme.numericMd, + .sm => textTheme.numericXl, + }; + + EdgeInsetsGeometry _paddingForSize( + StreamBadgeNotificationSize size, + StreamSpacing spacing, + ) => switch (size) { + .xs => EdgeInsets.symmetric(horizontal: spacing.xxs), + .sm => EdgeInsets.symmetric(horizontal: spacing.xxs), + }; +} + +class _StreamBadgeNotificationThemeDefaults extends StreamBadgeNotificationThemeData { + _StreamBadgeNotificationThemeDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + + @override + StreamBadgeNotificationSize get size => StreamBadgeNotificationSize.sm; + + @override + Color get primaryBackgroundColor => _colorScheme.accentPrimary; + + @override + Color get errorBackgroundColor => _colorScheme.accentError; + + @override + Color get neutralBackgroundColor => _colorScheme.accentNeutral; + + @override + Color get textColor => _colorScheme.textOnAccent; + + @override + Color get borderColor => _colorScheme.borderOnDark; +} diff --git a/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart b/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart index 82507c4..3751404 100644 --- a/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart +++ b/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart @@ -215,8 +215,6 @@ class DefaultStreamListTile extends StatelessWidget { if (props.selected) WidgetState.selected, }; - final textDirection = Directionality.of(context); - final effectiveTitleColor = (theme.titleColor ?? defaults.titleColor).resolve(states)!; final effectiveSubtitleColor = (theme.subtitleColor ?? defaults.subtitleColor).resolve(states)!; final effectiveDescriptionColor = (theme.descriptionColor ?? defaults.descriptionColor).resolve(states)!; @@ -225,19 +223,7 @@ class DefaultStreamListTile extends StatelessWidget { final effectiveTitleTextStyle = theme.titleTextStyle ?? defaults.titleTextStyle; final effectiveSubtitleTextStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; final effectiveDescriptionTextStyle = theme.descriptionTextStyle ?? defaults.descriptionTextStyle; - final effectiveBackgroundColor = (theme.backgroundColor ?? defaults.backgroundColor).resolve(states); - final effectiveShape = theme.shape ?? defaults.shape; - final effectiveContentPadding = (theme.contentPadding ?? defaults.contentPadding).resolve(textDirection); final effectiveMinTileHeight = theme.minTileHeight ?? defaults.minTileHeight; - final effectiveOverlayColor = theme.overlayColor ?? defaults.overlayColor; - - // Mouse cursor: show a non-interactive cursor when the tile is disabled - // OR when no gesture callbacks are wired. - final mouseStates = { - if (!props.enabled || (props.onTap == null && props.onLongPress == null)) WidgetState.disabled, - }; - - final effectiveMouseCursor = WidgetStateMouseCursor.clickable.resolve(mouseStates); Widget? leadingWidget; if (props.leading case final leading?) { @@ -284,47 +270,29 @@ class DefaultStreamListTile extends StatelessWidget { ); } - return InkWell( - customBorder: effectiveShape, - onTap: props.enabled ? props.onTap : null, - onLongPress: props.enabled ? props.onLongPress : null, - canRequestFocus: props.enabled, - mouseCursor: effectiveMouseCursor, - overlayColor: effectiveOverlayColor, - child: Semantics( - button: props.onTap != null || props.onLongPress != null, - selected: props.selected, - enabled: props.enabled, - child: Ink( - decoration: ShapeDecoration( - shape: effectiveShape, - color: effectiveBackgroundColor, - ), - child: SafeArea( - top: false, - bottom: false, - minimum: effectiveContentPadding, - child: IconTheme.merge( - data: IconThemeData(color: effectiveIconColor), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: effectiveMinTileHeight), - child: Row( - spacing: spacing.xs, - children: [ - ?leadingWidget, - Expanded( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [?titleWidget, ?subtitleWidget], - ), - ), - ?descriptionWidget, - ?trailingWidget, - ], + return StreamListTileContainer( + enabled: props.enabled, + selected: props.selected, + onTap: props.onTap, + onLongPress: props.onLongPress, + child: IconTheme.merge( + data: IconThemeData(color: effectiveIconColor), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: effectiveMinTileHeight), + child: Row( + spacing: spacing.xs, + children: [ + ?leadingWidget, + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [?titleWidget, ?subtitleWidget], ), ), - ), + ?descriptionWidget, + ?trailingWidget, + ], ), ), ), @@ -404,3 +372,74 @@ class _StreamListTileThemeDefaults extends StreamListTileThemeData { return StreamColors.transparent; }); } + +class StreamListTileContainer extends StatelessWidget { + const StreamListTileContainer({ + super.key, + required this.child, + required this.enabled, + required this.selected, + required this.onTap, + required this.onLongPress, + }); + + final Widget child; + + final bool enabled; + final bool selected; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final theme = context.streamListTileTheme; + final defaults = _StreamListTileThemeDefaults(context); + + // Build the WidgetState set once and share it across all color resolvers. + final states = { + if (!enabled) WidgetState.disabled, + if (selected) WidgetState.selected, + }; + + final textDirection = Directionality.of(context); + + final effectiveBackgroundColor = (theme.backgroundColor ?? defaults.backgroundColor).resolve(states); + final effectiveShape = theme.shape ?? defaults.shape; + final effectiveContentPadding = (theme.contentPadding ?? defaults.contentPadding).resolve(textDirection); + final effectiveOverlayColor = theme.overlayColor ?? defaults.overlayColor; + + // Mouse cursor: show a non-interactive cursor when the tile is disabled + // OR when no gesture callbacks are wired. + final mouseStates = { + if (!enabled || (onTap == null && onLongPress == null)) WidgetState.disabled, + }; + + final effectiveMouseCursor = WidgetStateMouseCursor.clickable.resolve(mouseStates); + + return InkWell( + customBorder: effectiveShape, + onTap: enabled ? onTap : null, + onLongPress: enabled ? onLongPress : null, + canRequestFocus: enabled, + mouseCursor: effectiveMouseCursor, + overlayColor: effectiveOverlayColor, + child: Semantics( + button: onTap != null || onLongPress != null, + selected: selected, + enabled: enabled, + child: Ink( + decoration: ShapeDecoration( + shape: effectiveShape, + color: effectiveBackgroundColor, + ), + child: SafeArea( + top: false, + bottom: false, + minimum: effectiveContentPadding, + child: child, + ), + ), + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index b90145e..d176b0d 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -135,6 +135,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? avatarGroup, StreamComponentBuilder? avatarStack, StreamComponentBuilder? badgeCount, + StreamComponentBuilder? badgeNotification, StreamComponentBuilder? button, StreamComponentBuilder? checkbox, StreamComponentBuilder? contextMenuAction, @@ -155,6 +156,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { avatarGroup: avatarGroup, avatarStack: avatarStack, badgeCount: badgeCount, + badgeNotification: badgeNotification, button: button, checkbox: checkbox, contextMenuAction: contextMenuAction, @@ -176,6 +178,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.avatarGroup, required this.avatarStack, required this.badgeCount, + required this.badgeNotification, required this.button, required this.checkbox, required this.contextMenuAction, @@ -229,6 +232,12 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamBadgeCount] uses [DefaultStreamBadgeCount]. final StreamComponentBuilder? badgeCount; + /// Custom builder for badge notification widgets. + /// + /// When null, [StreamBadgeNotification] uses + /// [DefaultStreamBadgeNotification]. + final StreamComponentBuilder? badgeNotification; + /// Custom builder for button widgets. /// /// When null, [StreamButton] uses [DefaultStreamButton]. diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart index e17defc..c8539d9 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart @@ -35,6 +35,7 @@ mixin _$StreamComponentBuilders { avatarGroup: t < 0.5 ? a.avatarGroup : b.avatarGroup, avatarStack: t < 0.5 ? a.avatarStack : b.avatarStack, badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, + badgeNotification: t < 0.5 ? a.badgeNotification : b.badgeNotification, button: t < 0.5 ? a.button : b.button, checkbox: t < 0.5 ? a.checkbox : b.checkbox, contextMenuAction: t < 0.5 ? a.contextMenuAction : b.contextMenuAction, @@ -55,6 +56,8 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamAvatarGroupProps)? avatarGroup, Widget Function(BuildContext, StreamAvatarStackProps)? avatarStack, Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, + Widget Function(BuildContext, StreamBadgeNotificationProps)? + badgeNotification, Widget Function(BuildContext, StreamButtonProps)? button, Widget Function(BuildContext, StreamCheckboxProps)? checkbox, Widget Function(BuildContext, StreamContextMenuActionProps)? @@ -77,6 +80,7 @@ mixin _$StreamComponentBuilders { avatarGroup: avatarGroup ?? _this.avatarGroup, avatarStack: avatarStack ?? _this.avatarStack, badgeCount: badgeCount ?? _this.badgeCount, + badgeNotification: badgeNotification ?? _this.badgeNotification, button: button ?? _this.button, checkbox: checkbox ?? _this.checkbox, contextMenuAction: contextMenuAction ?? _this.contextMenuAction, @@ -108,6 +112,7 @@ mixin _$StreamComponentBuilders { avatarGroup: other.avatarGroup, avatarStack: other.avatarStack, badgeCount: other.badgeCount, + badgeNotification: other.badgeNotification, button: other.button, checkbox: other.checkbox, contextMenuAction: other.contextMenuAction, @@ -140,6 +145,7 @@ mixin _$StreamComponentBuilders { _other.avatarGroup == _this.avatarGroup && _other.avatarStack == _this.avatarStack && _other.badgeCount == _this.badgeCount && + _other.badgeNotification == _this.badgeNotification && _other.button == _this.button && _other.checkbox == _this.checkbox && _other.contextMenuAction == _this.contextMenuAction && @@ -164,6 +170,7 @@ mixin _$StreamComponentBuilders { _this.avatarGroup, _this.avatarStack, _this.badgeCount, + _this.badgeNotification, _this.button, _this.checkbox, _this.contextMenuAction, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 8a13d4c..2371ac7 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -3,6 +3,7 @@ export 'factory/stream_component_factory.dart'; export 'theme/components/stream_audio_waveform_theme.dart'; export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; +export 'theme/components/stream_badge_notification_theme.dart'; export 'theme/components/stream_button_theme.dart'; export 'theme/components/stream_checkbox_theme.dart'; export 'theme/components/stream_context_menu_action_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart index eee2bb2..ea24406 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.dart @@ -27,8 +27,11 @@ enum StreamAvatarSize { /// Large avatar (40px diameter). lg(40), - /// Extra large avatar (64px diameter). - xl(64) + /// Extra large avatar (48px diameter). + xl(48), + + /// Extra-extra large avatar (80px diameter). + xxl(80) ; /// Constructs a [StreamAvatarSize] with the given diameter. diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_badge_notification_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_notification_theme.dart new file mode 100644 index 0000000..40fc0a0 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_notification_theme.dart @@ -0,0 +1,127 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_badge_notification_theme.g.theme.dart'; + +/// Predefined sizes for the badge notification indicator. +/// +/// Each size corresponds to a specific height in logical pixels. +/// +/// See also: +/// +/// * [StreamBadgeNotification], which uses these size variants. +/// * [StreamBadgeNotificationThemeData.size], for setting a global default. +enum StreamBadgeNotificationSize { + /// Extra small badge (16px height). + xs(16), + + /// Small badge (20px height). + sm(20) + ; + + /// Constructs a [StreamBadgeNotificationSize] with the given height. + const StreamBadgeNotificationSize(this.value); + + /// The height of the badge in logical pixels. + final double value; +} + +/// The visual type of a [StreamBadgeNotification]. +/// +/// Determines which background color is applied to the badge. +enum StreamBadgeNotificationType { + /// Primary style — uses the brand accent color. + primary, + + /// Error style — uses the error accent color. + error, + + /// Neutral style — uses a muted neutral color. + neutral, +} + +/// Applies a badge notification theme to descendant widgets. +/// +/// Wrap a subtree with [StreamBadgeNotificationTheme] to override badge +/// notification styling. Access the merged theme using +/// [BuildContext.streamBadgeNotificationTheme]. +/// +/// See also: +/// +/// * [StreamBadgeNotificationThemeData], which describes the theme. +/// * [StreamBadgeNotification], the widget affected by this theme. +class StreamBadgeNotificationTheme extends InheritedTheme { + /// Creates a badge notification theme. + const StreamBadgeNotificationTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The badge notification theme data for descendant widgets. + final StreamBadgeNotificationThemeData data; + + /// Returns the merged [StreamBadgeNotificationThemeData] from local and + /// global themes. + static StreamBadgeNotificationThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).badgeNotificationTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamBadgeNotificationTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamBadgeNotificationTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamBadgeNotification] widgets. +/// +/// See also: +/// +/// * [StreamBadgeNotification], the widget that uses this theme data. +/// * [StreamBadgeNotificationTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamBadgeNotificationThemeData with _$StreamBadgeNotificationThemeData { + /// Creates a badge notification theme with optional style overrides. + const StreamBadgeNotificationThemeData({ + this.size, + this.primaryBackgroundColor, + this.errorBackgroundColor, + this.neutralBackgroundColor, + this.textColor, + this.borderColor, + }); + + /// The default size for badge notifications. + /// + /// Falls back to [StreamBadgeNotificationSize.sm]. + final StreamBadgeNotificationSize? size; + + /// The background color for the [StreamBadgeNotificationType.primary] type. + final Color? primaryBackgroundColor; + + /// The background color for the [StreamBadgeNotificationType.error] type. + final Color? errorBackgroundColor; + + /// The background color for the [StreamBadgeNotificationType.neutral] type. + final Color? neutralBackgroundColor; + + /// The text color for the count label. + final Color? textColor; + + /// The border color of the badge. + final Color? borderColor; + + /// Linearly interpolate between two [StreamBadgeNotificationThemeData]. + static StreamBadgeNotificationThemeData? lerp( + StreamBadgeNotificationThemeData? a, + StreamBadgeNotificationThemeData? b, + double t, + ) => _$StreamBadgeNotificationThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_badge_notification_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_notification_theme.g.theme.dart new file mode 100644 index 0000000..cbb6539 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_notification_theme.g.theme.dart @@ -0,0 +1,134 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_badge_notification_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamBadgeNotificationThemeData { + bool get canMerge => true; + + static StreamBadgeNotificationThemeData? lerp( + StreamBadgeNotificationThemeData? a, + StreamBadgeNotificationThemeData? 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 StreamBadgeNotificationThemeData( + size: t < 0.5 ? a.size : b.size, + primaryBackgroundColor: Color.lerp( + a.primaryBackgroundColor, + b.primaryBackgroundColor, + t, + ), + errorBackgroundColor: Color.lerp( + a.errorBackgroundColor, + b.errorBackgroundColor, + t, + ), + neutralBackgroundColor: Color.lerp( + a.neutralBackgroundColor, + b.neutralBackgroundColor, + t, + ), + textColor: Color.lerp(a.textColor, b.textColor, t), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + ); + } + + StreamBadgeNotificationThemeData copyWith({ + StreamBadgeNotificationSize? size, + Color? primaryBackgroundColor, + Color? errorBackgroundColor, + Color? neutralBackgroundColor, + Color? textColor, + Color? borderColor, + }) { + final _this = (this as StreamBadgeNotificationThemeData); + + return StreamBadgeNotificationThemeData( + size: size ?? _this.size, + primaryBackgroundColor: + primaryBackgroundColor ?? _this.primaryBackgroundColor, + errorBackgroundColor: errorBackgroundColor ?? _this.errorBackgroundColor, + neutralBackgroundColor: + neutralBackgroundColor ?? _this.neutralBackgroundColor, + textColor: textColor ?? _this.textColor, + borderColor: borderColor ?? _this.borderColor, + ); + } + + StreamBadgeNotificationThemeData merge( + StreamBadgeNotificationThemeData? other, + ) { + final _this = (this as StreamBadgeNotificationThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + primaryBackgroundColor: other.primaryBackgroundColor, + errorBackgroundColor: other.errorBackgroundColor, + neutralBackgroundColor: other.neutralBackgroundColor, + textColor: other.textColor, + borderColor: other.borderColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamBadgeNotificationThemeData); + final _other = (other as StreamBadgeNotificationThemeData); + + return _other.size == _this.size && + _other.primaryBackgroundColor == _this.primaryBackgroundColor && + _other.errorBackgroundColor == _this.errorBackgroundColor && + _other.neutralBackgroundColor == _this.neutralBackgroundColor && + _other.textColor == _this.textColor && + _other.borderColor == _this.borderColor; + } + + @override + int get hashCode { + final _this = (this as StreamBadgeNotificationThemeData); + + return Object.hash( + runtimeType, + _this.size, + _this.primaryBackgroundColor, + _this.errorBackgroundColor, + _this.neutralBackgroundColor, + _this.textColor, + _this.borderColor, + ); + } +} 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 7ac1339..0189025 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -7,6 +7,7 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann import 'components/stream_audio_waveform_theme.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; +import 'components/stream_badge_notification_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; import 'components/stream_context_menu_action_theme.dart'; @@ -95,6 +96,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamAudioWaveformThemeData? audioWaveformTheme, StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, + StreamBadgeNotificationThemeData? badgeNotificationTheme, StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamContextMenuThemeData? contextMenuTheme, @@ -125,6 +127,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { audioWaveformTheme ??= const StreamAudioWaveformThemeData(); avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); + badgeNotificationTheme ??= const StreamBadgeNotificationThemeData(); buttonTheme ??= const StreamButtonThemeData(); checkboxTheme ??= const StreamCheckboxThemeData(); contextMenuTheme ??= const StreamContextMenuThemeData(); @@ -149,6 +152,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { audioWaveformTheme: audioWaveformTheme, avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, + badgeNotificationTheme: badgeNotificationTheme, buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, contextMenuTheme: contextMenuTheme, @@ -187,6 +191,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.audioWaveformTheme, required this.avatarTheme, required this.badgeCountTheme, + required this.badgeNotificationTheme, required this.buttonTheme, required this.checkboxTheme, required this.contextMenuTheme, @@ -267,6 +272,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The badge count theme for this theme. final StreamBadgeCountThemeData badgeCountTheme; + /// The badge notification theme for this theme. + final StreamBadgeNotificationThemeData badgeNotificationTheme; + /// The button theme for this theme. final StreamButtonThemeData buttonTheme; @@ -332,6 +340,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { audioWaveformTheme: audioWaveformTheme, avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, + badgeNotificationTheme: badgeNotificationTheme, buttonTheme: buttonTheme, checkboxTheme: checkboxTheme, contextMenuTheme: contextMenuTheme, 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 15c5a41..8ef6173 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 @@ -23,6 +23,7 @@ mixin _$StreamTheme on ThemeExtension { StreamAudioWaveformThemeData? audioWaveformTheme, StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, + StreamBadgeNotificationThemeData? badgeNotificationTheme, StreamButtonThemeData? buttonTheme, StreamCheckboxThemeData? checkboxTheme, StreamContextMenuThemeData? contextMenuTheme, @@ -49,6 +50,8 @@ mixin _$StreamTheme on ThemeExtension { audioWaveformTheme: audioWaveformTheme ?? _this.audioWaveformTheme, avatarTheme: avatarTheme ?? _this.avatarTheme, badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, + badgeNotificationTheme: + badgeNotificationTheme ?? _this.badgeNotificationTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, checkboxTheme: checkboxTheme ?? _this.checkboxTheme, contextMenuTheme: contextMenuTheme ?? _this.contextMenuTheme, @@ -100,6 +103,11 @@ mixin _$StreamTheme on ThemeExtension { other.badgeCountTheme, t, )!, + badgeNotificationTheme: StreamBadgeNotificationThemeData.lerp( + _this.badgeNotificationTheme, + other.badgeNotificationTheme, + t, + )!, buttonTheme: StreamButtonThemeData.lerp( _this.buttonTheme, other.buttonTheme, @@ -174,6 +182,7 @@ mixin _$StreamTheme on ThemeExtension { _other.audioWaveformTheme == _this.audioWaveformTheme && _other.avatarTheme == _this.avatarTheme && _other.badgeCountTheme == _this.badgeCountTheme && + _other.badgeNotificationTheme == _this.badgeNotificationTheme && _other.buttonTheme == _this.buttonTheme && _other.checkboxTheme == _this.checkboxTheme && _other.contextMenuTheme == _this.contextMenuTheme && @@ -204,6 +213,7 @@ mixin _$StreamTheme on ThemeExtension { _this.audioWaveformTheme, _this.avatarTheme, _this.badgeCountTheme, + _this.badgeNotificationTheme, _this.buttonTheme, _this.checkboxTheme, _this.contextMenuTheme, 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 7ad9b69..4917120 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 @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'components/stream_audio_waveform_theme.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; +import 'components/stream_badge_notification_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_checkbox_theme.dart'; import 'components/stream_context_menu_action_theme.dart'; @@ -77,6 +78,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamBadgeCountThemeData] from the nearest ancestor. StreamBadgeCountThemeData get streamBadgeCountTheme => StreamBadgeCountTheme.of(this); + /// Returns the [StreamBadgeNotificationThemeData] from the nearest ancestor. + StreamBadgeNotificationThemeData get streamBadgeNotificationTheme => StreamBadgeNotificationTheme.of(this); + /// Returns the [StreamButtonThemeData] from the nearest ancestor. StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_counts.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_counts.png new file mode 100644 index 0000000..f77fb2d Binary files /dev/null and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_counts.png differ diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_dark_matrix.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_dark_matrix.png new file mode 100644 index 0000000..b84574c Binary files /dev/null and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_light_matrix.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_light_matrix.png new file mode 100644 index 0000000..3a2ccd2 Binary files /dev/null and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_badge_notification_light_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/badge/stream_badge_notification_golden_test.dart b/packages/stream_core_flutter/test/components/badge/stream_badge_notification_golden_test.dart new file mode 100644 index 0000000..4c91179 --- /dev/null +++ b/packages/stream_core_flutter/test/components/badge/stream_badge_notification_golden_test.dart @@ -0,0 +1,92 @@ +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('StreamBadgeNotification Golden Tests', () { + goldenTest( + 'renders light theme type and size matrix', + fileName: 'stream_badge_notification_light_matrix', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 100), + children: [ + for (final type in StreamBadgeNotificationType.values) + for (final size in StreamBadgeNotificationSize.values) + GoldenTestScenario( + name: '${type.name}_${size.name}', + child: _buildInTheme( + StreamBadgeNotification( + label: '1', + type: type, + size: size, + ), + ), + ), + ], + ), + ); + + goldenTest( + 'renders dark theme type and size matrix', + fileName: 'stream_badge_notification_dark_matrix', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 100), + children: [ + for (final type in StreamBadgeNotificationType.values) + for (final size in StreamBadgeNotificationSize.values) + GoldenTestScenario( + name: '${type.name}_${size.name}', + child: _buildInTheme( + StreamBadgeNotification( + label: '1', + type: type, + size: size, + ), + brightness: Brightness.dark, + ), + ), + ], + ), + ); + + goldenTest( + 'renders count variants correctly', + fileName: 'stream_badge_notification_counts', + builder: () => GoldenTestGroup( + scenarioConstraints: const BoxConstraints(maxWidth: 100), + children: [ + for (final count in ['1', '9', '25', '99', '99+']) + GoldenTestScenario( + name: 'count_$count', + child: _buildInTheme( + StreamBadgeNotification(label: count), + ), + ), + ], + ), + ); + }); +} + +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: Padding( + padding: const EdgeInsets.all(8), + child: Center(child: child), + ), + ), + ), + ); +}