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 5192641..70f69ac 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 @@ -14,8 +14,12 @@ import 'package:design_system_gallery/components/button.dart' as _design_system_gallery_components_button; import 'package:design_system_gallery/components/stream_avatar.dart' as _design_system_gallery_components_stream_avatar; +import 'package:design_system_gallery/components/stream_avatar_group.dart' + as _design_system_gallery_components_stream_avatar_group; import 'package:design_system_gallery/components/stream_avatar_stack.dart' as _design_system_gallery_components_stream_avatar_stack; +import 'package:design_system_gallery/components/stream_badge_count.dart' + as _design_system_gallery_components_stream_badge_count; import 'package:design_system_gallery/components/stream_online_indicator.dart' as _design_system_gallery_components_stream_online_indicator; import 'package:design_system_gallery/primitives/colors.dart' @@ -35,7 +39,6 @@ import 'package:widgetbook/widgetbook.dart' as _widgetbook; final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookCategory( name: 'App Foundation', - isInitiallyExpanded: false, children: [ _widgetbook.WidgetbookFolder( name: 'Primitives', @@ -144,7 +147,6 @@ final directories = <_widgetbook.WidgetbookNode>[ children: [ _widgetbook.WidgetbookFolder( name: 'Avatar', - isInitiallyExpanded: false, children: [ _widgetbook.WidgetbookComponent( name: 'StreamAvatar', @@ -161,6 +163,21 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamAvatarGroup', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_stream_avatar_group + .buildStreamAvatarGroupPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: _design_system_gallery_components_stream_avatar_group + .buildStreamAvatarGroupShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamAvatarStack', useCases: [ @@ -178,9 +195,28 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Badge', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamBadgeCount', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_stream_badge_count + .buildStreamBadgeCountPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: _design_system_gallery_components_stream_badge_count + .buildStreamBadgeCountShowcase, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Button', - isInitiallyExpanded: false, children: [ _widgetbook.WidgetbookComponent( name: 'StreamButton', @@ -216,7 +252,6 @@ final directories = <_widgetbook.WidgetbookNode>[ ), _widgetbook.WidgetbookFolder( name: 'Indicator', - isInitiallyExpanded: false, children: [ _widgetbook.WidgetbookComponent( name: 'StreamOnlineIndicator', diff --git a/apps/design_system_gallery/lib/app/gallery_shell.dart b/apps/design_system_gallery/lib/app/gallery_shell.dart index 940837d..d9a5079 100644 --- a/apps/design_system_gallery/lib/app/gallery_shell.dart +++ b/apps/design_system_gallery/lib/app/gallery_shell.dart @@ -48,7 +48,7 @@ class GalleryShell extends StatelessWidget { lightTheme: materialTheme, darkTheme: materialTheme, themeMode: isDark ? .dark : .light, - directories: directories, + directories: _collapseDirectories(directories), home: const GalleryHomePage(), appBuilder: (context, child) => PreviewWrapper(child: child), ), @@ -68,3 +68,52 @@ class GalleryShell extends StatelessWidget { ); } } + +// Transforms a list of [WidgetbookNode]s to have their children collapsed +// by default. +// +// This recursively processes all nodes and creates new instances with +// `isInitiallyExpanded: false` for nodes that have children. +List _collapseDirectories( + List nodes, +) => nodes.map(_collapseNode).toList(); + +WidgetbookNode _collapseNode( + WidgetbookNode node, +) { + if (node is WidgetbookCategory) { + // Keep the category expanded by default, but collapse its children + return WidgetbookCategory( + name: node.name, + children: node.children?.map(_collapseNode).toList(), + ); + } + + if (node is WidgetbookFolder) { + // Keep the folder and its children collapsed by default + return WidgetbookFolder( + name: node.name, + isInitiallyExpanded: false, + children: node.children?.map(_collapseNode).toList(), + ); + } + + if (node is WidgetbookComponent) { + // Keep the component and its use cases collapsed by default + return WidgetbookComponent( + name: node.name, + isInitiallyExpanded: false, + useCases: [ + ...node.useCases.map( + (useCase) => WidgetbookUseCase( + name: useCase.name, + builder: useCase.builder, + designLink: useCase.designLink, + ), + ), + ], + ); + } + + return node; +} diff --git a/apps/design_system_gallery/lib/components/stream_avatar.dart b/apps/design_system_gallery/lib/components/stream_avatar.dart index e3f8e8b..b57d4c8 100644 --- a/apps/design_system_gallery/lib/components/stream_avatar.dart +++ b/apps/design_system_gallery/lib/components/stream_avatar.dart @@ -123,6 +123,7 @@ 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', }; } diff --git a/apps/design_system_gallery/lib/components/stream_avatar_group.dart b/apps/design_system_gallery/lib/components/stream_avatar_group.dart new file mode 100644 index 0000000..4d17b07 --- /dev/null +++ b/apps/design_system_gallery/lib/components/stream_avatar_group.dart @@ -0,0 +1,551 @@ +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; + +const _sampleImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=200', + 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=200', +]; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamAvatarGroup, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarGroupPlayground(BuildContext context) { + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamAvatarGroupSize.values, + initialOption: StreamAvatarGroupSize.xl, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'Avatar group diameter size preset.', + ); + + final avatarCount = context.knobs.int.slider( + label: 'Avatar Count', + initialValue: 4, + min: 1, + max: 5, + description: 'Number of avatars to display.', + ); + + final showImages = context.knobs.boolean( + label: 'Show Images', + initialValue: true, + description: 'Use images or show initials placeholder.', + ); + + final palette = context.streamColorScheme.avatarPalette; + + return Center( + child: StreamAvatarGroup( + size: size, + children: List.generate( + avatarCount, + (index) => StreamAvatar( + imageUrl: showImages ? _sampleImages[index % _sampleImages.length] : null, + backgroundColor: palette[index % palette.length].backgroundColor, + foregroundColor: palette[index % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(index)), + ), + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamAvatarGroup, + path: '[Components]/Avatar', +) +Widget buildStreamAvatarGroupShowcase(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: [ + // Size variants + const _SizeVariantsSection(), + SizedBox(height: spacing.xl), + + // Avatar count variants + const _AvatarCountSection(), + SizedBox(height: spacing.xl), + + // Usage patterns + const _UsagePatternsSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Size Variants Section +// ============================================================================= + +class _SizeVariantsSection extends StatelessWidget { + const _SizeVariantsSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'SIZE SCALE'), + SizedBox(height: spacing.md), + ...StreamAvatarGroupSize.values.map((size) => _SizeCard(size: size)), + ], + ); + } +} + +class _SizeCard extends StatelessWidget { + const _SizeCard({required this.size}); + + final StreamAvatarGroupSize size; + + String _getUsage(StreamAvatarGroupSize size) { + return switch (size) { + StreamAvatarGroupSize.lg => 'Channel list items, compact group displays', + StreamAvatarGroupSize.xl => 'Channel headers, prominent group displays', + }; + } + + @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; + final palette = colorScheme.avatarPalette; + + return Padding( + padding: EdgeInsets.only(bottom: spacing.sm), + child: Container( + 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.borderSurfaceSubtle), + ), + child: Row( + children: [ + // Avatar group preview + SizedBox( + width: 80, + height: 80, + child: Center( + child: StreamAvatarGroup( + size: size, + children: List.generate( + 4, + (index) => StreamAvatar( + imageUrl: _sampleImages[index % _sampleImages.length], + backgroundColor: palette[index % palette.length].backgroundColor, + foregroundColor: palette[index % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(index)), + ), + ), + ), + ), + ), + SizedBox(width: spacing.md + spacing.xs), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'StreamAvatarGroupSize.${size.name}', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + SizedBox(width: spacing.sm), + Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.xs + spacing.xxs, + vertical: spacing.xxs, + ), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + '${size.value.toInt()}px', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ), + ], + ), + SizedBox(height: spacing.xs + spacing.xxs), + Text( + _getUsage(size), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ============================================================================= +// Avatar Count Section +// ============================================================================= + +class _AvatarCountSection extends StatelessWidget { + const _AvatarCountSection(); + + @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; + final palette = colorScheme.avatarPalette; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'AVATAR COUNT'), + 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.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Group displays adapt based on member count', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.lg, + runSpacing: spacing.md, + children: [ + for (var count = 1; count <= 5; count++) + _CountDemo( + count: count, + children: List.generate( + count, + (index) => StreamAvatar( + imageUrl: _sampleImages[index % _sampleImages.length], + backgroundColor: palette[index % palette.length].backgroundColor, + foregroundColor: palette[index % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(index)), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _CountDemo extends StatelessWidget { + const _CountDemo({ + required this.count, + required this.children, + }); + + final int count; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamAvatarGroup( + size: StreamAvatarGroupSize.lg, + children: children, + ), + SizedBox(height: spacing.sm), + Text( + '$count ${count == 1 ? 'member' : 'members'}', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Usage Patterns Section +// ============================================================================= + +class _UsagePatternsSection extends StatelessWidget { + const _UsagePatternsSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + final palette = colorScheme.avatarPalette; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'USAGE PATTERNS'), + SizedBox(height: spacing.md), + + // Channel list item example + _ExampleCard( + title: 'Channel List Item', + description: 'Group avatar in channel list', + child: Row( + children: [ + StreamAvatarGroup( + size: StreamAvatarGroupSize.lg, + children: List.generate( + 3, + (index) => StreamAvatar( + imageUrl: _sampleImages[index % _sampleImages.length], + backgroundColor: palette[index % palette.length].backgroundColor, + foregroundColor: palette[index % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(index)), + ), + ), + ), + SizedBox(width: spacing.md), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Design Team', + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + '3 members', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ), + ), + SizedBox(height: spacing.sm), + + // Channel header example + _ExampleCard( + title: 'Channel Header', + description: 'Large group avatar for channel details', + child: Row( + children: [ + StreamAvatarGroup( + size: StreamAvatarGroupSize.xl, + children: List.generate( + 4, + (index) => StreamAvatar( + imageUrl: _sampleImages[index % _sampleImages.length], + backgroundColor: palette[index % palette.length].backgroundColor, + foregroundColor: palette[index % palette.length].foregroundColor, + placeholder: (context) => Text(_getInitials(index)), + ), + ), + ), + SizedBox(width: spacing.md), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Product Launch', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + '4 participants', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.title, + required this.description, + required this.child, + }); + + final String title; + final String description; + final Widget child; + + @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 Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB(spacing.md, spacing.sm, spacing.md, spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + description, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.borderSurfaceSubtle, + ), + // Content + Container( + padding: EdgeInsets.all(spacing.md), + color: colorScheme.backgroundSurface, + child: child, + ), + ], + ), + ); + } +} + +// ============================================================================= +// 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, + ), + ), + ); + } +} + +String _getInitials(int index) { + const names = ['AB', 'CD', 'EF', 'GH', 'IJ']; + return names[index % names.length]; +} diff --git a/apps/design_system_gallery/lib/components/stream_avatar_stack.dart b/apps/design_system_gallery/lib/components/stream_avatar_stack.dart index 4a3612c..3ccb3e5 100644 --- a/apps/design_system_gallery/lib/components/stream_avatar_stack.dart +++ b/apps/design_system_gallery/lib/components/stream_avatar_stack.dart @@ -31,8 +31,8 @@ Widget buildStreamAvatarStackPlayground(BuildContext context) { final size = context.knobs.object.dropdown( label: 'Size', - options: StreamAvatarSize.values, - initialOption: StreamAvatarSize.md, + options: StreamAvatarStackSize.values, + initialOption: StreamAvatarStackSize.sm, labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', description: 'Size of each avatar in the stack.', ); @@ -248,7 +248,7 @@ class _OverlapDemo extends StatelessWidget { return Column( children: [ StreamAvatarStack( - size: StreamAvatarSize.sm, + size: StreamAvatarStackSize.sm, overlap: overlap, children: [ for (var i = 0; i < 3; i++) @@ -294,7 +294,7 @@ class _MaxDemo extends StatelessWidget { return Column( children: [ StreamAvatarStack( - size: StreamAvatarSize.sm, + size: StreamAvatarStackSize.sm, max: max, children: [ for (var i = 0; i < count; i++) @@ -346,7 +346,7 @@ class _UsagePatternsSection extends StatelessWidget { child: Row( children: [ StreamAvatarStack( - size: StreamAvatarSize.sm, + size: StreamAvatarStackSize.sm, children: [ for (var i = 0; i < 3; i++) StreamAvatar( @@ -388,7 +388,7 @@ class _UsagePatternsSection extends StatelessWidget { child: Row( children: [ StreamAvatarStack( - size: StreamAvatarSize.sm, + size: StreamAvatarStackSize.sm, max: 4, children: [ for (var i = 0; i < 8; i++) diff --git a/apps/design_system_gallery/lib/components/stream_badge_count.dart b/apps/design_system_gallery/lib/components/stream_badge_count.dart new file mode 100644 index 0000000..0ce558e --- /dev/null +++ b/apps/design_system_gallery/lib/components/stream_badge_count.dart @@ -0,0 +1,560 @@ +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: StreamBadgeCount, + path: '[Components]/Badge', +) +Widget buildStreamBadgeCountPlayground(BuildContext context) { + final label = context.knobs.string( + label: 'Label', + initialValue: '5', + description: 'The text to display in the badge.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamBadgeCountSize.values, + initialOption: StreamBadgeCountSize.xs, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'The size of the badge.', + ); + + return Center( + child: StreamBadgeCount( + label: label, + size: size, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamBadgeCount, + path: '[Components]/Badge', +) +Widget buildStreamBadgeCountShowcase(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: [ + // Size variants + const _SizeVariantsSection(), + SizedBox(height: spacing.xl), + + // Count variants + const _CountVariantsSection(), + SizedBox(height: spacing.xl), + + // Usage patterns + const _UsagePatternsSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// 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.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Badge sizes scale with count display', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final size in StreamBadgeCountSize.values) ...[ + _SizeDemo(size: size), + if (size != StreamBadgeCountSize.values.last) SizedBox(width: spacing.xl), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +class _SizeDemo extends StatelessWidget { + const _SizeDemo({required this.size}); + + final StreamBadgeCountSize size; + + String _getPixelSize(StreamBadgeCountSize size) { + return switch (size) { + StreamBadgeCountSize.xs => '20px', + StreamBadgeCountSize.sm => '24px', + StreamBadgeCountSize.md => '32px', + }; + } + + @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: StreamBadgeCount( + label: '5', + size: size, + ), + ), + ), + SizedBox(height: spacing.sm), + Text( + size.name.toUpperCase(), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + _getPixelSize(size), + 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.borderSurfaceSubtle), + ), + 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: [ + StreamBadgeCount(label: displayText), + SizedBox(height: spacing.xs), + Text( + displayText, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Usage Patterns Section +// ============================================================================= + +class _UsagePatternsSection extends StatelessWidget { + const _UsagePatternsSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'USAGE PATTERNS'), + SizedBox(height: spacing.md), + + // Avatar with badge + const _ExampleCard( + title: 'Avatar with Badge', + description: 'Badge positioned on avatar corner', + child: _AvatarWithBadgeGroup(), + ), + SizedBox(height: spacing.sm), + + // List item with badge + const _ExampleCard( + title: 'List Item', + description: 'Badge in channel list item', + child: _ListItemExample(), + ), + ], + ); + } +} + +class _AvatarWithBadgeGroup extends StatelessWidget { + const _AvatarWithBadgeGroup(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Row( + children: [ + const _AvatarWithBadge(name: 'John', count: 3), + SizedBox(width: spacing.lg), + const _AvatarWithBadge(name: 'Sarah', count: 12), + SizedBox(width: spacing.lg), + const _AvatarWithBadge(name: 'Alex', count: 99), + SizedBox(width: spacing.lg), + const _AvatarWithBadge(name: 'Team', count: 150), + ], + ); + } +} + +class _AvatarWithBadge extends StatelessWidget { + const _AvatarWithBadge({ + required this.name, + required this.count, + }); + + final String name; + final int count; + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => Text(name[0]), + ), + Positioned( + right: -4, + top: -4, + child: StreamBadgeCount( + label: count > 99 ? '99+' : '$count', + size: StreamBadgeCountSize.xs, + ), + ), + ], + ), + SizedBox(height: spacing.sm), + Text( + name, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ], + ); + } +} + +class _ListItemExample extends StatelessWidget { + const _ListItemExample(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + children: [ + const _ChannelListItem( + name: 'Design Team', + message: 'New mockups ready for review', + count: 3, + ), + SizedBox(height: spacing.sm), + const _ChannelListItem( + name: 'Engineering', + message: 'PR merged successfully', + count: 12, + ), + ], + ); + } +} + +class _ChannelListItem extends StatelessWidget { + const _ChannelListItem({ + required this.name, + required this.message, + required this.count, + }); + + final String name; + final String message; + final int count; + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + return Row( + children: [ + StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => Text(name[0]), + ), + SizedBox(width: spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: textTheme.bodyEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + message, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + SizedBox(width: spacing.sm), + StreamBadgeCount(label: '$count'), + ], + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.title, + required this.description, + required this.child, + }); + + final String title; + final String description; + final Widget child; + + @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 Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: EdgeInsets.fromLTRB(spacing.md, spacing.sm, spacing.md, spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + description, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + Divider( + height: 1, + color: colorScheme.borderSurfaceSubtle, + ), + // Content + Container( + padding: EdgeInsets.all(spacing.md), + color: colorScheme.backgroundSurface, + child: child, + ), + ], + ), + ); + } +} + +// ============================================================================= +// 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/apps/design_system_gallery/lib/components/stream_online_indicator.dart b/apps/design_system_gallery/lib/components/stream_online_indicator.dart index b5f2c35..17385b7 100644 --- a/apps/design_system_gallery/lib/components/stream_online_indicator.dart +++ b/apps/design_system_gallery/lib/components/stream_online_indicator.dart @@ -27,6 +27,69 @@ Widget buildStreamOnlineIndicatorPlayground(BuildContext context) { description: 'The size of the indicator.', ); + final withChild = context.knobs.boolean( + label: 'With Child', + description: 'Wrap an avatar as child (Badge-like behavior).', + ); + + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: const [ + AlignmentDirectional.topStart, + AlignmentDirectional.topCenter, + AlignmentDirectional.topEnd, + AlignmentDirectional.centerStart, + AlignmentDirectional.center, + AlignmentDirectional.centerEnd, + AlignmentDirectional.bottomStart, + AlignmentDirectional.bottomCenter, + AlignmentDirectional.bottomEnd, + ], + initialOption: AlignmentDirectional.topEnd, + labelBuilder: (option) => switch (option) { + AlignmentDirectional.topStart => 'Top Start', + AlignmentDirectional.topCenter => 'Top Center', + AlignmentDirectional.topEnd => 'Top End', + AlignmentDirectional.centerStart => 'Center Start', + AlignmentDirectional.center => 'Center', + AlignmentDirectional.centerEnd => 'Center End', + AlignmentDirectional.bottomStart => 'Bottom Start', + AlignmentDirectional.bottomCenter => 'Bottom Center', + AlignmentDirectional.bottomEnd => 'Bottom End', + _ => option.toString(), + }, + description: 'Alignment of indicator relative to child (directional for RTL support).', + ); + + final offsetX = context.knobs.double.slider( + label: 'Offset X', + min: -10, + max: 10, + description: 'Horizontal offset for fine-tuning position.', + ); + + final offsetY = context.knobs.double.slider( + label: 'Offset Y', + min: -10, + max: 10, + description: 'Vertical offset for fine-tuning position.', + ); + + if (withChild) { + return Center( + child: StreamOnlineIndicator( + isOnline: isOnline, + size: size, + alignment: alignment, + offset: Offset(offsetX, offsetY), + child: StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => const Text('AB'), + ), + ), + ); + } + return Center( child: StreamOnlineIndicator( isOnline: isOnline, @@ -60,6 +123,10 @@ Widget buildStreamOnlineIndicatorShowcase(BuildContext context) { const _SizeVariantsSection(), SizedBox(height: spacing.xl), + // Alignment variants + const _AlignmentVariantsSection(), + SizedBox(height: spacing.xl), + // Usage patterns const _UsagePatternsSection(), ], @@ -158,6 +225,7 @@ class _SizeDemo extends StatelessWidget { StreamOnlineIndicatorSize.sm => '8px', StreamOnlineIndicatorSize.md => '12px', StreamOnlineIndicatorSize.lg => '14px', + StreamOnlineIndicatorSize.xl => '16px', }; } @@ -200,6 +268,115 @@ class _SizeDemo extends StatelessWidget { } } +// ============================================================================= +// Alignment Variants Section +// ============================================================================= + +class _AlignmentVariantsSection extends StatelessWidget { + const _AlignmentVariantsSection(); + + @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: 'ALIGNMENT 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.borderSurfaceSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Badge-like positioning with child', + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.xl, + runSpacing: spacing.lg, + children: const [ + _AlignmentDemo( + alignment: AlignmentDirectional.topStart, + label: 'topStart', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.topEnd, + label: 'topEnd', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.bottomStart, + label: 'bottomStart', + ), + _AlignmentDemo( + alignment: AlignmentDirectional.bottomEnd, + label: 'bottomEnd', + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _AlignmentDemo extends StatelessWidget { + const _AlignmentDemo({required this.alignment, required this.label}); + + final AlignmentGeometry alignment; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamOnlineIndicator( + isOnline: true, + size: StreamOnlineIndicatorSize.lg, + alignment: alignment, + child: StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => const Text('AB'), + ), + ), + SizedBox(height: spacing.sm), + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + // ============================================================================= // Usage Patterns Section // ============================================================================= @@ -274,22 +451,14 @@ class _AvatarWithIndicator extends StatelessWidget { return Column( children: [ - Stack( - clipBehavior: Clip.none, - children: [ - StreamAvatar( - size: StreamAvatarSize.lg, - placeholder: (context) => Text(initials), - ), - Positioned( - right: 0, - top: 0, - child: StreamOnlineIndicator( - isOnline: isOnline, - size: StreamOnlineIndicatorSize.lg, - ), - ), - ], + // Using the new child parameter (Badge-like behavior) + StreamOnlineIndicator( + isOnline: isOnline, + size: StreamOnlineIndicatorSize.lg, + child: StreamAvatar( + size: StreamAvatarSize.lg, + placeholder: (context) => Text(initials), + ), ), SizedBox(height: spacing.sm), Text( diff --git a/apps/design_system_gallery/lib/config/preview_configuration.dart b/apps/design_system_gallery/lib/config/preview_configuration.dart index 7ec158d..8adf352 100644 --- a/apps/design_system_gallery/lib/config/preview_configuration.dart +++ b/apps/design_system_gallery/lib/config/preview_configuration.dart @@ -1,10 +1,10 @@ import 'package:device_frame_plus/device_frame_plus.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; /// Preview configuration for device frame and text scale. /// -/// Manages the device frame, text scale, and device frame visibility -/// for the widget preview area. +/// Manages the device frame, text scale, text direction, and device frame +/// visibility for the widget preview area. class PreviewConfiguration extends ChangeNotifier { PreviewConfiguration(); @@ -15,6 +15,7 @@ class PreviewConfiguration extends ChangeNotifier { DeviceInfo _selectedDevice = Devices.ios.iPhone13ProMax; var _textScale = 1.0; var _showDeviceFrame = false; + var _textDirection = TextDirection.ltr; // ========================================================================= // Getters @@ -23,6 +24,7 @@ class PreviewConfiguration extends ChangeNotifier { DeviceInfo get selectedDevice => _selectedDevice; double get textScale => _textScale; bool get showDeviceFrame => _showDeviceFrame; + TextDirection get textDirection => _textDirection; // ========================================================================= // Static Options @@ -39,6 +41,8 @@ class PreviewConfiguration extends ChangeNotifier { static const textScaleOptions = [0.85, 1, 1.15, 1.3, 2]; + static const textDirectionOptions = TextDirection.values; + // ========================================================================= // Setters // ========================================================================= @@ -59,4 +63,10 @@ class PreviewConfiguration extends ChangeNotifier { _showDeviceFrame = !_showDeviceFrame; notifyListeners(); } + + void setTextDirection(TextDirection direction) { + if (_textDirection == direction) return; + _textDirection = direction; + notifyListeners(); + } } diff --git a/apps/design_system_gallery/lib/core/preview_wrapper.dart b/apps/design_system_gallery/lib/core/preview_wrapper.dart index bff9b67..3ef9fb4 100644 --- a/apps/design_system_gallery/lib/core/preview_wrapper.dart +++ b/apps/design_system_gallery/lib/core/preview_wrapper.dart @@ -23,13 +23,16 @@ class PreviewWrapper extends StatelessWidget { final radius = context.streamRadius; final spacing = context.streamSpacing; - final content = MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(previewConfig.textScale), - ), - child: ColoredBox( - color: colorScheme.backgroundApp, - child: child, + final content = Directionality( + textDirection: previewConfig.textDirection, + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(previewConfig.textScale), + ), + child: ColoredBox( + color: colorScheme.backgroundApp, + child: child, + ), ), ); diff --git a/apps/design_system_gallery/lib/semantics/typography.dart b/apps/design_system_gallery/lib/semantics/typography.dart index 717edcb..96ea346 100644 --- a/apps/design_system_gallery/lib/semantics/typography.dart +++ b/apps/design_system_gallery/lib/semantics/typography.dart @@ -88,6 +88,7 @@ class _TypeScale extends StatelessWidget { 'NUMERIC', 'Numbers and counters', [ + ('numericXl', textTheme.numericXl, 'Extra large counters'), ('numericLg', textTheme.numericLg, 'Large counters, stats'), ('numericMd', textTheme.numericMd, 'Badges, indicators'), ('numericSm', textTheme.numericSm, 'Small counts'), @@ -431,7 +432,7 @@ class _CompleteReference extends StatelessWidget { ), _SizeTag( label: 'numeric', - sizes: '22 / 14 / 10', + sizes: '14 / 12 / 10 / 8', ), ], ), diff --git a/apps/design_system_gallery/lib/widgets/toolbar/text_direction_selector.dart b/apps/design_system_gallery/lib/widgets/toolbar/text_direction_selector.dart new file mode 100644 index 0000000..6fb9294 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/text_direction_selector.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A dropdown selector for choosing the text direction (LTR/RTL). +class TextDirectionSelector extends StatelessWidget { + const TextDirectionSelector({ + super.key, + required this.value, + required this.options, + required this.onChanged, + }); + + final TextDirection value; + final List options; + final ValueChanged onChanged; + + @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), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSurfaceSubtle), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + icon: Icon( + Icons.unfold_more, + color: colorScheme.textTertiary, + size: 16, + ), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textPrimary, + ), + dropdownColor: colorScheme.backgroundSurface, + items: options.map((direction) { + final isLtr = direction == TextDirection.ltr; + return DropdownMenuItem( + value: direction, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isLtr ? Icons.format_textdirection_l_to_r : Icons.format_textdirection_r_to_l, + size: 14, + color: colorScheme.textTertiary, + ), + SizedBox(width: spacing.sm), + Text( + isLtr ? 'LTR' : 'RTL', + style: textTheme.captionDefault, + ), + ], + ), + ); + }).toList(), + onChanged: (direction) { + if (direction != null) onChanged(direction); + }, + ), + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart index d6828b7..b48ed76 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -7,6 +7,7 @@ import '../../config/preview_configuration.dart'; import '../../config/theme_configuration.dart'; import '../../core/stream_icons.dart'; import 'device_selector.dart'; +import 'text_direction_selector.dart'; import 'text_scale_selector.dart'; import 'theme_mode_toggle.dart'; import 'toolbar_button.dart'; @@ -80,6 +81,14 @@ class GalleryToolbar extends StatelessWidget { options: PreviewConfiguration.textScaleOptions, onChanged: previewConfig.setTextScale, ), + SizedBox(width: spacing.sm), + + // Text direction selector (LTR/RTL) + TextDirectionSelector( + value: previewConfig.textDirection, + options: PreviewConfiguration.textDirectionOptions, + onChanged: previewConfig.setTextDirection, + ), ], ), ), diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart index c6c2206..b59b13a 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart @@ -4,6 +4,7 @@ library; export 'device_selector.dart'; +export 'text_direction_selector.dart'; export 'text_scale_selector.dart'; export 'theme_mode_toggle.dart'; export 'toolbar.dart'; diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index c99f49a..f353da5 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -1,4 +1,6 @@ export 'components/avatar/stream_avatar.dart'; +export 'components/avatar/stream_avatar_group.dart'; export 'components/avatar/stream_avatar_stack.dart'; +export 'components/badge/stream_badge_count.dart'; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; export 'components/indicator/stream_online_indicator.dart'; 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 6e9155a..806134f 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 @@ -122,8 +122,8 @@ class StreamAvatar extends StatelessWidget { /// Whether to show a border around the avatar. /// - /// Defaults to true. The border color is determined by - /// [StreamAvatarThemeData.borderColor]. + /// Defaults to true. The border style is determined by + /// [StreamAvatarThemeData.border]. final bool showBorder; @override @@ -136,9 +136,9 @@ class StreamAvatar extends StatelessWidget { final effectiveSize = size ?? avatarTheme.size ?? defaults.size; final effectiveBackgroundColor = backgroundColor ?? avatarTheme.backgroundColor ?? defaults.backgroundColor; final effectiveForegroundColor = foregroundColor ?? avatarTheme.foregroundColor ?? defaults.foregroundColor; - final effectiveBorderColor = avatarTheme.borderColor ?? defaults.borderColor; + final effectiveBorder = avatarTheme.border ?? defaults.border; - final border = showBorder ? Border.all(color: effectiveBorderColor) : null; + final border = showBorder ? effectiveBorder : null; final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveForegroundColor); final iconTheme = theme.iconTheme.copyWith(color: effectiveForegroundColor, size: _iconSizeForSize(effectiveSize)); @@ -184,6 +184,7 @@ class StreamAvatar extends StatelessWidget { .xs => textTheme.metadataEmphasis, .sm || .md => textTheme.captionEmphasis, .lg => textTheme.bodyEmphasis, + .xl => textTheme.headingLg, }; // Returns the appropriate icon size for the given avatar size. @@ -194,6 +195,7 @@ class StreamAvatar extends StatelessWidget { .sm => 12, .md => 16, .lg => 20, + .xl => 32, }; } @@ -216,7 +218,7 @@ class _StreamAvatarThemeDefaults extends StreamAvatarThemeData { StreamAvatarSize get size => StreamAvatarSize.lg; @override - Color get borderColor => StreamColors.black10; + BoxBorder get border => Border.all(color: StreamColors.black10); @override Color get backgroundColor => _colorScheme.avatarPalette.first.backgroundColor; 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 new file mode 100644 index 0000000..7ab07be --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_avatar_theme.dart'; +import '../../theme/components/stream_badge_count_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import '../badge/stream_badge_count.dart'; +import 'stream_avatar.dart'; + +/// Predefined avatar group sizes. +/// +/// Each size corresponds to a specific diameter in logical pixels. +/// +/// See also: +/// +/// * [StreamAvatarGroup], which uses these size variants. +enum StreamAvatarGroupSize { + /// Large avatar group (40px diameter). + lg(40), + + /// Extra large avatar group (64px diameter). + xl(64) + ; + + /// Constructs a [StreamAvatarGroupSize] with the given diameter. + const StreamAvatarGroupSize(this.value); + + /// The diameter of the avatar group in logical pixels. + final double value; +} + +/// A widget that displays multiple avatars in a grid layout. +/// +/// [StreamAvatarGroup] arranges avatars in a 2x2 grid pattern, typically used +/// for displaying group channel participants. It supports two sizes and +/// automatically handles overflow with a [StreamBadgeCount] indicator. +/// +/// The avatar automatically handles: +/// - Grid layout for up to 4 avatars +/// - Overflow indicator using [StreamBadgeCount] for additional participants +/// - Consistent sizing across all child avatars +/// +/// {@tool snippet} +/// +/// Basic usage with avatars: +/// +/// ```dart +/// StreamAvatarGroup( +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// StreamAvatar(placeholder: (context) => Text('C')), +/// StreamAvatar(placeholder: (context) => Text('D')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom size: +/// +/// ```dart +/// StreamAvatarGroup( +/// size: StreamAvatarGroupSize.xl, +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamAvatarGroup] uses [StreamAvatarThemeData] for styling the child +/// avatars. Colors are inherited from the theme or can be customized +/// per-avatar. +/// +/// See also: +/// +/// * [StreamAvatarGroupSize], which defines the available size variants. +/// * [StreamAvatar], the individual avatar widget. +/// * [StreamBadgeCount], used for the overflow indicator. +/// * [StreamAvatarThemeData], which provides theme-level customization. +class StreamAvatarGroup extends StatelessWidget { + /// Creates a Stream avatar group. + const StreamAvatarGroup({ + super.key, + this.size, + required this.children, + }); + + /// The list of avatars to display in the group. + /// + /// Typically a list of [StreamAvatar] widgets. + final Iterable children; + + /// The size of the avatar group. + /// + /// If null, defaults to [StreamAvatarGroupSize.lg]. + final StreamAvatarGroupSize? size; + + @override + Widget build(BuildContext context) { + if (children.isEmpty) return const SizedBox.shrink(); + + final colorScheme = context.streamColorScheme; + + final effectiveSize = size ?? StreamAvatarGroupSize.lg; + final avatarSize = _avatarSizeForGroupSize(effectiveSize); + final badgeCountSize = _badgeCountSizeForGroupSize(effectiveSize); + + const avatarBorderWidth = 2.0; + + return AnimatedContainer( + width: effectiveSize.value, + height: effectiveSize.value, + duration: kThemeChangeDuration, + // Need to disable text scaling here so that the text doesn't + // escape the avatar when the textScaleFactor is large. + child: MediaQuery.withNoTextScaling( + child: StreamAvatarTheme( + data: StreamAvatarThemeData( + size: avatarSize, + border: Border.all( + width: avatarBorderWidth, + color: colorScheme.borderOnDark, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: StreamBadgeCountTheme( + data: StreamBadgeCountThemeData(size: badgeCountSize), + child: Builder( + builder: (context) => switch (children.length) { + 1 => _buildForOne(context, children), + 2 => _buildForTwo(context, children), + 3 => _buildForThree(context, children), + 4 => _buildForFour(context, children), + _ => _buildForFourOrMore(context, children), + }, + ), + ), + ), + ), + ); + } + + // Build the widget for 1 avatar. + Widget _buildForOne( + BuildContext context, + Iterable avatars, + ) { + final avatarOne = avatars.first; + + return Stack( + clipBehavior: .none, + children: [ + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topStart, + child: StreamAvatar( + placeholder: (context) => Icon(context.streamIcons.people), + ), + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomEnd, + child: avatarOne, + ), + ), + ], + ); + } + + // Build the widget for 2 avatars. + Widget _buildForTwo( + BuildContext context, + Iterable avatars, + ) { + final avatarOne = avatars.first; + final avatarTwo = avatars.last; + + return Stack( + clipBehavior: .none, + children: [ + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topStart, + child: avatarOne, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomEnd, + child: avatarTwo, + ), + ), + ], + ); + } + + // Builds the widget for 3 avatars. + Widget _buildForThree( + BuildContext context, + Iterable avatars, + ) { + final avatarOne = avatars.first; + final avatarTwo = avatars.elementAt(1); + final avatarThree = avatars.last; + + return Stack( + clipBehavior: .none, + children: [ + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topCenter, + child: avatarOne, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomStart, + child: avatarTwo, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomEnd, + child: avatarThree, + ), + ), + ], + ); + } + + // Builds the widget for 4 avatars. + Widget _buildForFour( + BuildContext context, + Iterable avatars, + ) { + final avatarOne = avatars.first; + final avatarTwo = avatars.elementAt(1); + final avatarThree = avatars.elementAt(2); + final avatarFour = avatars.last; + + return Stack( + clipBehavior: .none, + children: [ + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topStart, + child: avatarOne, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topEnd, + child: avatarTwo, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomStart, + child: avatarThree, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomEnd, + child: avatarFour, + ), + ), + ], + ); + } + + // Builds the widget for 4 or more avatars. + Widget _buildForFourOrMore( + BuildContext context, + Iterable avatars, + ) { + final avatarOne = avatars.first; + final avatarTwo = avatars.elementAt(1); + final extraCount = avatars.length - 2; + + return Stack( + clipBehavior: .none, + children: [ + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topStart, + child: avatarOne, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.topEnd, + child: avatarTwo, + ), + ), + Positioned.fill( + child: Align( + alignment: AlignmentDirectional.bottomCenter, + child: StreamBadgeCount(label: '+$extraCount'), + ), + ), + ], + ); + } + + // Returns the appropriate avatar size for the given group size. + StreamAvatarSize _avatarSizeForGroupSize( + StreamAvatarGroupSize size, + ) => switch (size) { + .lg => StreamAvatarSize.sm, + .xl => StreamAvatarSize.lg, + }; + + // Returns the appropriate badge count size for the given group size. + StreamBadgeCountSize _badgeCountSizeForGroupSize( + StreamAvatarGroupSize size, + ) => switch (size) { + .lg => StreamBadgeCountSize.sm, + .xl => StreamBadgeCountSize.md, + }; +} diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart index 9394164..3046e00 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -1,8 +1,32 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import '../../theme/components/stream_avatar_theme.dart'; -import '../../theme/stream_theme_extensions.dart'; -import 'stream_avatar.dart'; +import '../../theme/components/stream_badge_count_theme.dart'; +import '../badge/stream_badge_count.dart'; + +/// Predefined avatar stack sizes. +/// +/// Each size corresponds to a specific diameter in logical pixels. +/// +/// See also: +/// +/// * [StreamAvatarStack], which uses these size variants. +enum StreamAvatarStackSize { + /// Extra small avatar stack (20px diameter). + xs(20), + + /// Small avatar stack (24px diameter). + sm(24) + ; + + /// Constructs a [StreamAvatarStackSize] with the given diameter. + const StreamAvatarStackSize(this.value); + + /// The diameter of the avatar stack in logical pixels. + final double value; +} /// A widget that displays a stack of [StreamAvatar] widgets with overlap. /// @@ -26,7 +50,7 @@ import 'stream_avatar.dart'; /// /// {@tool snippet} /// -/// With max limit showing "+X" for overflow: +/// With max limit showing overflow badge: /// /// ```dart /// StreamAvatarStack( @@ -39,7 +63,7 @@ import 'stream_avatar.dart'; /// StreamAvatar(placeholder: (context) => Text('E')), /// ], /// ) -/// // Shows: [A] [B] [C] [+2] +/// // Shows: [A] [B] [C] [StreamBadgeCount with +2] /// ``` /// {@end-tool} /// @@ -49,7 +73,7 @@ import 'stream_avatar.dart'; /// /// ```dart /// StreamAvatarStack( -/// size: StreamAvatarSize.sm, +/// size: StreamAvatarStackSize.sm, /// overlap: 0.5, // 50% overlap /// children: [ /// StreamAvatar(placeholder: (context) => Text('A')), @@ -62,7 +86,9 @@ import 'stream_avatar.dart'; /// /// See also: /// +/// * [StreamAvatarStackSize], which defines the available size variants. /// * [StreamAvatar], the individual avatar widget. +/// * [StreamBadgeCount], used for the overflow indicator. /// * [StreamAvatarThemeData], for customizing avatar theme properties. class StreamAvatarStack extends StatelessWidget { /// Creates a [StreamAvatarStack] with the given children. @@ -76,19 +102,17 @@ class StreamAvatarStack extends StatelessWidget { required this.children, this.overlap = 0.33, this.max = 5, - this.extraAvatarBuilder, }) : assert(max >= 2, 'max must be at least 2'); /// The list of widgets to display in the stack. /// /// Typically a list of [StreamAvatar] widgets. - final List children; + final Iterable children; - /// The size of the avatars in the stack. + /// The size of the avatar stack. /// - /// If null, uses [StreamAvatarThemeData.size], or falls back to - /// [StreamAvatarSize.lg]. - final StreamAvatarSize? size; + /// If null, defaults to [StreamAvatarStackSize.sm]. + final StreamAvatarStackSize? size; /// How much each avatar overlaps the previous one, as a fraction of size. /// @@ -97,46 +121,24 @@ class StreamAvatarStack extends StatelessWidget { /// - `1.0`: Fully stacked final double overlap; - /// Maximum number of avatars to display before showing "+X". + /// Maximum number of avatars to display before showing overflow badge. /// /// When [children] exceeds this value, displays [max] avatars followed - /// by a "+X" avatar showing the overflow count. + /// by a [StreamBadgeCount] showing the overflow count. /// /// Must be at least 2. Defaults to 5. final int max; - /// Builder for the extra avatar showing the overflow count. - /// - /// If null, a default [StreamAvatar] with "+X" text is used. - /// - /// The [extraCount] parameter indicates how many avatars are hidden. - /// - /// {@tool snippet} - /// - /// Custom extra avatar: - /// - /// ```dart - /// StreamAvatarStack( - /// max: 3, - /// extraAvatarBuilder: (context, extraCount) => StreamAvatar( - /// backgroundColor: Colors.grey, - /// placeholder: (context) => Text('+$extraCount'), - /// ), - /// children: [...], - /// ) - /// ``` - /// {@end-tool} - final Widget Function(BuildContext context, int extraCount)? extraAvatarBuilder; - @override Widget build(BuildContext context) { if (children.isEmpty) return const SizedBox.shrink(); - final avatarTheme = context.streamAvatarTheme; - final colorScheme = context.streamColorScheme; + final effectiveSize = size ?? StreamAvatarStackSize.sm; + final avatarSize = _avatarSizeForStackSize(effectiveSize); + final extraBadgeSize = _badgeCountSizeForStackSize(effectiveSize); - final effectiveSize = size ?? avatarTheme.size ?? .lg; - final diameter = effectiveSize.value; + final diameter = avatarSize.value; + final badgeDiameter = extraBadgeSize.value; // Split children into visible and overflow final visible = children.take(max).toList(); @@ -145,40 +147,55 @@ class StreamAvatarStack extends StatelessWidget { // Build the list of widgets to display final displayChildren = [ ...visible, - if (extraCount > 0) ...[ - switch (extraAvatarBuilder) { - final builder? => builder.call(context, extraCount), - _ => StreamAvatar( - backgroundColor: colorScheme.backgroundSurfaceStrong, - foregroundColor: colorScheme.textSecondary, - placeholder: (context) => Text('+$extraCount'), - ), - }, - ], + if (extraCount > 0) StreamBadgeCount(label: '+$extraCount', size: extraBadgeSize), ]; // Calculate the offset between each avatar (how much of each avatar is visible) final visiblePortion = diameter * (1 - overlap); + final badgeVisiblePortion = badgeDiameter * (1 - overlap); // Total width: first avatar full + remaining avatars visible portion - final totalWidth = diameter + (displayChildren.length - 1) * visiblePortion; + var totalWidth = diameter + (visible.length - 1) * visiblePortion; + if (extraCount > 0) totalWidth += badgeVisiblePortion; - return SizedBox( + return AnimatedContainer( width: totalWidth, - height: diameter, - child: Stack( - alignment: .center, - children: [ - for (var i = 0; i < displayChildren.length; i++) - Positioned( - left: i * visiblePortion, - child: StreamAvatarTheme( - data: StreamAvatarThemeData(size: effectiveSize), - child: displayChildren[i], - ), - ), - ], + height: math.max(diameter, badgeDiameter), + duration: kThemeChangeDuration, + // Need to disable text scaling here so that the text doesn't + // escape the avatar when the textScaleFactor is large. + child: MediaQuery.withNoTextScaling( + child: StreamAvatarTheme( + data: StreamAvatarThemeData(size: avatarSize), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + for (var i = 0; i < displayChildren.length; i++) + Positioned( + left: i * visiblePortion, + child: displayChildren[i], + ), + ], + ), + ), ), ); } + + // Returns the appropriate avatar size for the given stack size. + StreamAvatarSize _avatarSizeForStackSize( + StreamAvatarStackSize size, + ) => switch (size) { + .xs => StreamAvatarSize.xs, + .sm => StreamAvatarSize.sm, + }; + + // Returns the appropriate badge count size for the given stack size. + StreamBadgeCountSize _badgeCountSizeForStackSize( + StreamAvatarStackSize size, + ) => switch (size) { + .xs => StreamBadgeCountSize.xs, + .sm => StreamBadgeCountSize.sm, + }; } diff --git a/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart new file mode 100644 index 0000000..468d684 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/badge/stream_badge_count.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +import '../../theme/components/stream_badge_count_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 badge component for displaying counts or labels. +/// +/// [StreamBadgeCount] displays a text label in a pill-shaped badge. +/// It's typically positioned on avatars, icons, or list items to indicate +/// new messages, notifications, overflow counts, or other information. +/// +/// The badge automatically handles: +/// - Adapting width based on the label length +/// - Consistent styling across size variants +/// - Proper text theming from [StreamBadgeCountThemeData] +/// +/// {@tool snippet} +/// +/// Basic usage with a count: +/// +/// ```dart +/// StreamBadgeCount(label: '5') +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Positioned on an avatar: +/// +/// ```dart +/// Stack( +/// children: [ +/// StreamAvatar(placeholder: (context) => Text('AB')), +/// Positioned( +/// right: 0, +/// top: 0, +/// child: StreamBadgeCount(label: '3'), +/// ), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Overflow indicator: +/// +/// ```dart +/// StreamBadgeCount( +/// label: '+5', +/// size: StreamBadgeCountSize.sm, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamBadgeCount] uses [StreamBadgeCountThemeData] for default styling. +/// Colors are determined by the current [StreamColorScheme]. +/// +/// See also: +/// +/// * [StreamBadgeCountSize], which defines the available size variants. +/// * [StreamBadgeCountThemeData], for customizing badge appearance. +/// * [StreamBadgeCountTheme], for overriding theme in a widget subtree. +/// * [StreamAvatar], which often displays this badge. +class StreamBadgeCount extends StatelessWidget { + /// Creates a badge count indicator. + const StreamBadgeCount({ + super.key, + this.size, + required this.label, + }); + + /// The text label to display in the badge. + /// + /// Typically a numeric count (e.g., "5") or an overflow indicator + /// (e.g., "+3", "99+"). + final String label; + + /// The size of the badge. + /// + /// If null, uses [StreamBadgeCountThemeData.size], or falls back to + /// [StreamBadgeCountSize.xs]. + final StreamBadgeCountSize? size; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final boxShadow = context.streamBoxShadow; + final textTheme = context.streamTextTheme; + + final badgeCountTheme = context.streamBadgeCountTheme; + final defaults = _StreamBadgeCountThemeDefaults(context); + + final effectiveSize = size ?? badgeCountTheme.size ?? defaults.size; + final effectiveBackgroundColor = badgeCountTheme.backgroundColor ?? defaults.backgroundColor; + final effectiveBorderColor = badgeCountTheme.borderColor ?? defaults.borderColor; + final effectiveTextColor = badgeCountTheme.textColor ?? defaults.textColor; + + final padding = _paddingForSize(effectiveSize, spacing); + final textStyle = _textStyleForSize(effectiveSize, textTheme).copyWith(color: effectiveTextColor); + + return IntrinsicWidth( + child: AnimatedContainer( + height: effectiveSize.value, + constraints: BoxConstraints(minWidth: effectiveSize.value), + padding: padding, + alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + duration: kThemeChangeDuration, + decoration: ShapeDecoration( + color: effectiveBackgroundColor, + shape: const StadiumBorder(), + shadows: boxShadow.elevation2, + ), + foregroundDecoration: ShapeDecoration( + shape: StadiumBorder( + side: BorderSide( + color: effectiveBorderColor, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + ), + child: DefaultTextStyle( + style: textStyle, + child: Text(label), + ), + ), + ); + } + + // Returns the appropriate text style for the given badge size. + TextStyle _textStyleForSize( + StreamBadgeCountSize size, + StreamTextTheme textTheme, + ) => switch (size) { + .xs => textTheme.numericMd, + .sm || .md => textTheme.numericXl, + }; + + // Returns the appropriate padding for the given badge size. + EdgeInsetsGeometry _paddingForSize( + StreamBadgeCountSize size, + StreamSpacing spacing, + ) => switch (size) { + .xs => .symmetric(horizontal: spacing.xxs), + .sm || .md => .symmetric(horizontal: spacing.xs), + }; +} + +// Default theme values for [StreamBadgeCount]. +// +// These defaults are used when no explicit value is provided via +// constructor parameters or [StreamBadgeCountThemeData]. +// +// The defaults are context-aware and use colors from the current +// [StreamColorScheme]. +class _StreamBadgeCountThemeDefaults extends StreamBadgeCountThemeData { + _StreamBadgeCountThemeDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + + @override + StreamBadgeCountSize get size => StreamBadgeCountSize.xs; + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + Color get borderColor => _colorScheme.borderSubtle; + + @override + Color get textColor => _colorScheme.textPrimary; +} diff --git a/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart b/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart index b75d14f..fc0acc5 100644 --- a/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart +++ b/packages/stream_core_flutter/lib/src/components/indicator/stream_online_indicator.dart @@ -4,39 +4,17 @@ import '../../theme/components/stream_online_indicator_theme.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/stream_theme_extensions.dart'; -/// Predefined sizes for the online indicator. -/// -/// Each size corresponds to a specific diameter in logical pixels. -/// -/// See also: -/// -/// * [StreamOnlineIndicator], which uses these size variants. -/// * [StreamOnlineIndicatorThemeData], for customizing indicator appearance. -enum StreamOnlineIndicatorSize { - /// Small indicator (8px diameter). - sm(8), - - /// Medium indicator (12px diameter). - md(12), - - /// Large indicator (14px diameter). - lg(14) - ; - - const StreamOnlineIndicatorSize(this.value); - - /// The diameter of the indicator in logical pixels. - final double value; -} - /// A circular indicator showing online/offline presence status. /// /// This indicator is typically positioned on or near an avatar to show /// whether a user is currently online or offline. /// +/// When [child] is provided, the indicator is automatically positioned +/// relative to the child using a [Stack], similar to Flutter's [Badge] widget. +/// /// {@tool snippet} /// -/// Basic usage: +/// Basic usage (standalone indicator): /// /// ```dart /// StreamOnlineIndicator(isOnline: true) @@ -45,18 +23,26 @@ enum StreamOnlineIndicatorSize { /// /// {@tool snippet} /// -/// Positioned on an avatar: +/// With a child widget (automatically positioned): /// /// ```dart -/// Stack( -/// children: [ -/// StreamAvatar(placeholder: (context) => Text('AB')), -/// Positioned( -/// right: 0, -/// bottom: 0, -/// child: StreamOnlineIndicator(isOnline: user.isOnline), -/// ), -/// ], +/// StreamOnlineIndicator( +/// isOnline: user.isOnline, +/// child: StreamAvatar(placeholder: (context) => Text('AB')), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Custom positioning: +/// +/// ```dart +/// StreamOnlineIndicator( +/// isOnline: true, +/// alignment: Alignment.topRight, +/// offset: Offset(2, -2), +/// child: StreamAvatar(placeholder: (context) => Text('AB')), /// ) /// ``` /// {@end-tool} @@ -80,10 +66,16 @@ enum StreamOnlineIndicatorSize { /// * [StreamAvatar], which often displays this indicator. class StreamOnlineIndicator extends StatelessWidget { /// Creates an online indicator. + /// + /// If [child] is provided, the indicator will be positioned relative to the + /// child using [alignment] and [offset]. const StreamOnlineIndicator({ super.key, required this.isOnline, this.size, + this.child, + this.alignment, + this.offset, }); /// Whether the user is online. @@ -94,15 +86,35 @@ class StreamOnlineIndicator extends StatelessWidget { /// The size of the indicator. /// - /// Defaults to [StreamOnlineIndicatorSize.lg]. + /// If null, uses [StreamOnlineIndicatorThemeData.size], or falls back to + /// [StreamOnlineIndicatorSize.lg]. final StreamOnlineIndicatorSize? size; + /// The widget below this widget in the tree. + /// + /// When provided, the indicator is positioned relative to this child + /// using a [Stack]. When null, only the indicator is displayed. + final Widget? child; + + /// The alignment of the indicator relative to [child]. + /// + /// Only used when [child] is provided. + /// Falls back to [StreamOnlineIndicatorThemeData.alignment], or + /// [AlignmentDirectional.topEnd]. + final AlignmentGeometry? alignment; + + /// The offset for fine-tuning indicator position. + /// + /// Applied after [alignment] to adjust the indicator's final position. + /// Falls back to [StreamOnlineIndicatorThemeData.offset], or [Offset.zero]. + final Offset? offset; + @override Widget build(BuildContext context) { final onlineIndicatorTheme = context.streamOnlineIndicatorTheme; final defaults = _StreamOnlineIndicatorThemeDefaults(context); - final effectiveSize = size ?? StreamOnlineIndicatorSize.lg; + final effectiveSize = size ?? onlineIndicatorTheme.size ?? defaults.size; final effectiveBackgroundOnline = onlineIndicatorTheme.backgroundOnline ?? defaults.backgroundOnline; final effectiveBackgroundOffline = onlineIndicatorTheme.backgroundOffline ?? defaults.backgroundOffline; final effectiveBorderColor = onlineIndicatorTheme.borderColor ?? defaults.borderColor; @@ -110,12 +122,35 @@ class StreamOnlineIndicator extends StatelessWidget { final color = isOnline ? effectiveBackgroundOnline : effectiveBackgroundOffline; final border = Border.all(color: effectiveBorderColor, width: _borderWidthForSize(effectiveSize)); - return AnimatedContainer( + final indicator = AnimatedContainer( width: effectiveSize.value, height: effectiveSize.value, duration: kThemeChangeDuration, - decoration: BoxDecoration(shape: .circle, color: color), - foregroundDecoration: BoxDecoration(shape: .circle, border: border), + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + foregroundDecoration: BoxDecoration(shape: BoxShape.circle, border: border), + ); + + // If no child, just return the indicator. + if (child == null) return indicator; + + // Otherwise, wrap in Stack like Badge. + final effectiveAlignment = alignment ?? onlineIndicatorTheme.alignment ?? defaults.alignment; + final effectiveOffset = offset ?? onlineIndicatorTheme.offset ?? defaults.offset; + + return Stack( + clipBehavior: Clip.none, + children: [ + child!, + Positioned.fill( + child: Align( + alignment: effectiveAlignment, + child: Transform.translate( + offset: effectiveOffset, + child: indicator, + ), + ), + ), + ], ); } @@ -124,7 +159,7 @@ class StreamOnlineIndicator extends StatelessWidget { StreamOnlineIndicatorSize size, ) => switch (size) { .sm => 1, - .md || .lg => 2, + .md || .lg || .xl => 2, }; } @@ -138,6 +173,9 @@ class _StreamOnlineIndicatorThemeDefaults extends StreamOnlineIndicatorThemeData final BuildContext context; final StreamColorScheme _colorScheme; + @override + StreamOnlineIndicatorSize get size => StreamOnlineIndicatorSize.lg; + @override Color get backgroundOnline => _colorScheme.accentSuccess; @@ -146,4 +184,10 @@ class _StreamOnlineIndicatorThemeDefaults extends StreamOnlineIndicatorThemeData @override Color get borderColor => _colorScheme.borderOnDark; + + @override + AlignmentGeometry get alignment => AlignmentDirectional.topEnd; + + @override + Offset get offset => Offset.zero; } diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 344e628..f9832cb 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -1,4 +1,5 @@ export 'theme/components/stream_avatar_theme.dart'; +export 'theme/components/stream_badge_count_theme.dart'; export 'theme/components/stream_online_indicator_theme.dart'; export 'theme/primitives/stream_colors.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 947f7bd..eee2bb2 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 @@ -25,7 +25,10 @@ enum StreamAvatarSize { md(32), /// Large avatar (40px diameter). - lg(40) + lg(40), + + /// Extra large avatar (64px diameter). + xl(64) ; /// Constructs a [StreamAvatarSize] with the given diameter. @@ -107,7 +110,7 @@ class StreamAvatarTheme extends InheritedTheme { /// avatarTheme: StreamAvatarThemeData( /// backgroundColor: Colors.grey.shade200, /// foregroundColor: Colors.grey.shade800, -/// borderColor: Colors.grey.shade300, +/// border: Border.all(color: Colors.grey.shade300, width: 2), /// ), /// ) /// ``` @@ -126,7 +129,7 @@ class StreamAvatarThemeData with _$StreamAvatarThemeData { this.size, this.backgroundColor, this.foregroundColor, - this.borderColor, + this.border, }); /// The default size for avatars. @@ -145,10 +148,11 @@ class StreamAvatarThemeData with _$StreamAvatarThemeData { /// Applied to text (initials) and icons when no image is available. final Color? foregroundColor; - /// The border color for this avatar. + /// The border for this avatar. /// - /// Applied when [StreamAvatar.showBorder] is true. - final Color? borderColor; + /// Applied when [StreamAvatar.showBorder] is true. Allows customization + /// of both border color and width. + final BoxBorder? border; /// Linearly interpolate between two [StreamAvatarThemeData] objects. static StreamAvatarThemeData? lerp( diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart index f156a05..4a2b883 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_avatar_theme.g.theme.dart @@ -33,7 +33,7 @@ mixin _$StreamAvatarThemeData { size: t < 0.5 ? a.size : b.size, backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t), - borderColor: Color.lerp(a.borderColor, b.borderColor, t), + border: BoxBorder.lerp(a.border, b.border, t), ); } @@ -41,7 +41,7 @@ mixin _$StreamAvatarThemeData { StreamAvatarSize? size, Color? backgroundColor, Color? foregroundColor, - Color? borderColor, + BoxBorder? border, }) { final _this = (this as StreamAvatarThemeData); @@ -49,7 +49,7 @@ mixin _$StreamAvatarThemeData { size: size ?? _this.size, backgroundColor: backgroundColor ?? _this.backgroundColor, foregroundColor: foregroundColor ?? _this.foregroundColor, - borderColor: borderColor ?? _this.borderColor, + border: border ?? _this.border, ); } @@ -68,7 +68,7 @@ mixin _$StreamAvatarThemeData { size: other.size, backgroundColor: other.backgroundColor, foregroundColor: other.foregroundColor, - borderColor: other.borderColor, + border: other.border, ); } @@ -88,7 +88,7 @@ mixin _$StreamAvatarThemeData { return _other.size == _this.size && _other.backgroundColor == _this.backgroundColor && _other.foregroundColor == _this.foregroundColor && - _other.borderColor == _this.borderColor; + _other.border == _this.border; } @override @@ -100,7 +100,7 @@ mixin _$StreamAvatarThemeData { _this.size, _this.backgroundColor, _this.foregroundColor, - _this.borderColor, + _this.border, ); } } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_badge_count_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_count_theme.dart new file mode 100644 index 0000000..bba210c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_count_theme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_badge_count_theme.g.theme.dart'; + +/// Predefined sizes for the badge count indicator. +/// +/// Each size corresponds to a specific height in logical pixels. +/// +/// See also: +/// +/// * [StreamBadgeCount], which uses these size variants. +/// * [StreamBadgeCountThemeData.size], for setting a global default size. +enum StreamBadgeCountSize { + /// Extra small badge (20px height). + xs(20), + + /// Small badge (24px height). + sm(24), + + /// Medium badge (32px height). + md(32) + ; + + /// Constructs a [StreamBadgeCountSize] with the given height. + const StreamBadgeCountSize(this.value); + + /// The height of the badge in logical pixels. + final double value; +} + +/// Applies a badge count theme to descendant [StreamBadgeCount] widgets. +/// +/// Wrap a subtree with [StreamBadgeCountTheme] to override badge styling. +/// Access the merged theme using [BuildContext.streamBadgeCountTheme]. +/// +/// {@tool snippet} +/// +/// Override badge colors for a specific section: +/// +/// ```dart +/// StreamBadgeCountTheme( +/// data: StreamBadgeCountThemeData( +/// backgroundColor: Colors.red, +/// textColor: Colors.white, +/// ), +/// child: StreamBadgeCount(label: '5'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBadgeCountThemeData], which describes the badge theme. +/// * [StreamBadgeCount], the widget affected by this theme. +class StreamBadgeCountTheme extends InheritedTheme { + /// Creates a badge count theme that controls descendant badges. + const StreamBadgeCountTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The badge count theme data for descendant widgets. + final StreamBadgeCountThemeData data; + + /// Returns the [StreamBadgeCountThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamBadgeCountTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides - for example, overriding only + /// [backgroundColor] while inheriting other properties from the global theme. + static StreamBadgeCountThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).badgeCountTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamBadgeCountTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamBadgeCountTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamBadgeCount] widgets. +/// +/// {@tool snippet} +/// +/// Customize badge appearance globally: +/// +/// ```dart +/// StreamTheme( +/// badgeCountTheme: StreamBadgeCountThemeData( +/// size: StreamBadgeCountSize.sm, +/// backgroundColor: Colors.red, +/// textColor: Colors.white, +/// borderColor: Colors.white, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamBadgeCount], the widget that uses this theme data. +/// * [StreamBadgeCountTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamBadgeCountThemeData with _$StreamBadgeCountThemeData { + /// Creates a badge count theme with optional style overrides. + const StreamBadgeCountThemeData({ + this.size, + this.textColor, + this.backgroundColor, + this.borderColor, + }); + + /// The default size for badge counts. + /// + /// Falls back to [StreamBadgeCountSize.xs]. + final StreamBadgeCountSize? size; + + /// The text color for the count label. + /// + /// Applied to the numeric count displayed inside the badge. + final Color? textColor; + + /// The background color of the badge. + /// + /// The fill color behind the count text. Typically uses a color that + /// contrasts with the surface it's placed on. + final Color? backgroundColor; + + /// The border color of the badge. + /// + /// A thin outline around the badge that helps separate it from the + /// underlying content. + final Color? borderColor; + + /// Linearly interpolate between two [StreamBadgeCountThemeData] objects. + static StreamBadgeCountThemeData? lerp( + StreamBadgeCountThemeData? a, + StreamBadgeCountThemeData? b, + double t, + ) => _$StreamBadgeCountThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_badge_count_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_count_theme.g.theme.dart new file mode 100644 index 0000000..99450d3 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_badge_count_theme.g.theme.dart @@ -0,0 +1,106 @@ +// 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_count_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamBadgeCountThemeData { + bool get canMerge => true; + + static StreamBadgeCountThemeData? lerp( + StreamBadgeCountThemeData? a, + StreamBadgeCountThemeData? 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 StreamBadgeCountThemeData( + size: t < 0.5 ? a.size : b.size, + textColor: Color.lerp(a.textColor, b.textColor, t), + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + ); + } + + StreamBadgeCountThemeData copyWith({ + StreamBadgeCountSize? size, + Color? textColor, + Color? backgroundColor, + Color? borderColor, + }) { + final _this = (this as StreamBadgeCountThemeData); + + return StreamBadgeCountThemeData( + size: size ?? _this.size, + textColor: textColor ?? _this.textColor, + backgroundColor: backgroundColor ?? _this.backgroundColor, + borderColor: borderColor ?? _this.borderColor, + ); + } + + StreamBadgeCountThemeData merge(StreamBadgeCountThemeData? other) { + final _this = (this as StreamBadgeCountThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + textColor: other.textColor, + backgroundColor: other.backgroundColor, + borderColor: other.borderColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamBadgeCountThemeData); + final _other = (other as StreamBadgeCountThemeData); + + return _other.size == _this.size && + _other.textColor == _this.textColor && + _other.backgroundColor == _this.backgroundColor && + _other.borderColor == _this.borderColor; + } + + @override + int get hashCode { + final _this = (this as StreamBadgeCountThemeData); + + return Object.hash( + runtimeType, + _this.size, + _this.textColor, + _this.backgroundColor, + _this.borderColor, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart index 4e7ff1b..b273e9c 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.dart @@ -5,6 +5,35 @@ import '../stream_theme.dart'; part 'stream_online_indicator_theme.g.theme.dart'; +/// Predefined sizes for the online indicator. +/// +/// Each size corresponds to a specific diameter in logical pixels. +/// +/// See also: +/// +/// * [StreamOnlineIndicator], which uses these size variants. +/// * [StreamOnlineIndicatorThemeData.size], for setting a global default size. +enum StreamOnlineIndicatorSize { + /// Small indicator (8px diameter). + sm(8), + + /// Medium indicator (12px diameter). + md(12), + + /// Large indicator (14px diameter). + lg(14), + + /// Extra large indicator (16px diameter). + xl(16) + ; + + /// Constructs a [StreamOnlineIndicatorSize] with the given diameter. + const StreamOnlineIndicatorSize(this.value); + + /// The diameter of the indicator in logical pixels. + final double value; +} + /// Applies an online indicator theme to descendant [StreamOnlineIndicator] /// widgets. /// @@ -94,11 +123,19 @@ class StreamOnlineIndicatorTheme extends InheritedTheme { class StreamOnlineIndicatorThemeData with _$StreamOnlineIndicatorThemeData { /// Creates an online indicator theme with optional style overrides. const StreamOnlineIndicatorThemeData({ + this.size, this.backgroundOnline, this.backgroundOffline, this.borderColor, + this.alignment, + this.offset, }); + /// The default size for online indicators. + /// + /// Falls back to [StreamOnlineIndicatorSize.lg]. + final StreamOnlineIndicatorSize? size; + /// The background color for online presence indicators. /// /// Displayed when the user is currently online. @@ -115,6 +152,18 @@ class StreamOnlineIndicatorThemeData with _$StreamOnlineIndicatorThemeData { /// the avatar. final Color? borderColor; + /// The alignment of the indicator relative to the child widget. + /// + /// Only used when [StreamOnlineIndicator.child] is provided. + /// Falls back to [AlignmentDirectional.topEnd]. + final AlignmentGeometry? alignment; + + /// The offset for fine-tuning indicator position. + /// + /// Applied after alignment to adjust the indicator's final position. + /// Falls back to [Offset.zero]. + final Offset? offset; + /// Linearly interpolate between two [StreamOnlineIndicatorThemeData] objects. static StreamOnlineIndicatorThemeData? lerp( StreamOnlineIndicatorThemeData? a, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart index b975cd8..a2b96d9 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_online_indicator_theme.g.theme.dart @@ -30,6 +30,7 @@ mixin _$StreamOnlineIndicatorThemeData { } return StreamOnlineIndicatorThemeData( + size: t < 0.5 ? a.size : b.size, backgroundOnline: Color.lerp(a.backgroundOnline, b.backgroundOnline, t), backgroundOffline: Color.lerp( a.backgroundOffline, @@ -37,20 +38,28 @@ mixin _$StreamOnlineIndicatorThemeData { t, ), borderColor: Color.lerp(a.borderColor, b.borderColor, t), + alignment: AlignmentGeometry.lerp(a.alignment, b.alignment, t), + offset: Offset.lerp(a.offset, b.offset, t), ); } StreamOnlineIndicatorThemeData copyWith({ + StreamOnlineIndicatorSize? size, Color? backgroundOnline, Color? backgroundOffline, Color? borderColor, + AlignmentGeometry? alignment, + Offset? offset, }) { final _this = (this as StreamOnlineIndicatorThemeData); return StreamOnlineIndicatorThemeData( + size: size ?? _this.size, backgroundOnline: backgroundOnline ?? _this.backgroundOnline, backgroundOffline: backgroundOffline ?? _this.backgroundOffline, borderColor: borderColor ?? _this.borderColor, + alignment: alignment ?? _this.alignment, + offset: offset ?? _this.offset, ); } @@ -66,9 +75,12 @@ mixin _$StreamOnlineIndicatorThemeData { } return copyWith( + size: other.size, backgroundOnline: other.backgroundOnline, backgroundOffline: other.backgroundOffline, borderColor: other.borderColor, + alignment: other.alignment, + offset: other.offset, ); } @@ -85,9 +97,12 @@ mixin _$StreamOnlineIndicatorThemeData { final _this = (this as StreamOnlineIndicatorThemeData); final _other = (other as StreamOnlineIndicatorThemeData); - return _other.backgroundOnline == _this.backgroundOnline && + return _other.size == _this.size && + _other.backgroundOnline == _this.backgroundOnline && _other.backgroundOffline == _this.backgroundOffline && - _other.borderColor == _this.borderColor; + _other.borderColor == _this.borderColor && + _other.alignment == _this.alignment && + _other.offset == _this.offset; } @override @@ -96,9 +111,12 @@ mixin _$StreamOnlineIndicatorThemeData { return Object.hash( runtimeType, + _this.size, _this.backgroundOnline, _this.backgroundOffline, _this.borderColor, + _this.alignment, + _this.offset, ); } } diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 1ff1ea0..7879930 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -100,7 +100,7 @@ class StreamColorScheme with _$StreamColorScheme { // Accent accentPrimary ??= brand.shade500; - accentSuccess ??= StreamColors.green.shade500; + accentSuccess ??= StreamColors.green.shade300; accentWarning ??= StreamColors.yellow.shade500; accentError ??= StreamColors.red.shade500; accentNeutral ??= StreamColors.slate.shade500; @@ -278,7 +278,7 @@ class StreamColorScheme with _$StreamColorScheme { // Accent accentPrimary ??= brand.shade500; - accentSuccess ??= StreamColors.green.shade400; + accentSuccess ??= StreamColors.green.shade200; accentWarning ??= StreamColors.yellow.shade400; accentError ??= StreamColors.red.shade400; accentNeutral ??= StreamColors.neutral.shade500; diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart index e5ccf52..730709e 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.dart @@ -50,6 +50,7 @@ class StreamTextTheme with _$StreamTextTheme { TextStyle? metadataEmphasis, TextStyle? metadataLink, TextStyle? metadataLinkEmphasis, + TextStyle? numericXl, TextStyle? numericLg, TextStyle? numericMd, TextStyle? numericSm, @@ -175,6 +176,13 @@ class StreamTextTheme with _$StreamTextTheme { ); // Numeric styles + numericXl ??= TextStyle( + fontSize: fontSize.sm, + fontWeight: fontWeight.bold, + height: 14 / fontSize.sm, + fontStyle: FontStyle.normal, + decoration: TextDecoration.none, + ); numericLg ??= TextStyle( fontSize: fontSize.xs, fontWeight: fontWeight.bold, @@ -213,6 +221,7 @@ class StreamTextTheme with _$StreamTextTheme { metadataEmphasis: metadataEmphasis, metadataLink: metadataLink, metadataLinkEmphasis: metadataLinkEmphasis, + numericXl: numericXl, numericLg: numericLg, numericMd: numericMd, numericSm: numericSm, @@ -235,6 +244,7 @@ class StreamTextTheme with _$StreamTextTheme { required this.metadataEmphasis, required this.metadataLink, required this.metadataLinkEmphasis, + required this.numericXl, required this.numericLg, required this.numericMd, required this.numericSm, @@ -315,6 +325,11 @@ class StreamTextTheme with _$StreamTextTheme { /// Uses semibold weight, xs font size, and tight line height. final TextStyle metadataLinkEmphasis; + /// Extra large numeric text style. + /// + /// Uses bold weight and sm font size. Optimized for displaying numbers. + final TextStyle numericXl; + /// Large numeric text style. /// /// Uses bold weight and xs font size. Optimized for displaying numbers. @@ -531,6 +546,17 @@ class StreamTextTheme with _$StreamTextTheme { fontSizeDelta: fontSizeDelta, decoration: decoration, ), + numericXl: numericXl.apply( + color: color, + package: package, + fontFamily: fontFamily, + fontFamilyFallback: fontFamilyFallback, + heightFactor: heightFactor, + heightDelta: heightDelta, + fontSizeFactor: fontSizeFactor, + fontSizeDelta: fontSizeDelta, + decoration: decoration, + ), numericLg: numericLg.apply( color: color, package: package, diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart index 8bfb022..90cd369 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_text_theme.g.theme.dart @@ -61,6 +61,7 @@ mixin _$StreamTextTheme { b.metadataLinkEmphasis, t, )!, + numericXl: TextStyle.lerp(a.numericXl, b.numericXl, t)!, numericLg: TextStyle.lerp(a.numericLg, b.numericLg, t)!, numericMd: TextStyle.lerp(a.numericMd, b.numericMd, t)!, numericSm: TextStyle.lerp(a.numericSm, b.numericSm, t)!, @@ -83,6 +84,7 @@ mixin _$StreamTextTheme { TextStyle? metadataEmphasis, TextStyle? metadataLink, TextStyle? metadataLinkEmphasis, + TextStyle? numericXl, TextStyle? numericLg, TextStyle? numericMd, TextStyle? numericSm, @@ -105,6 +107,7 @@ mixin _$StreamTextTheme { metadataEmphasis: metadataEmphasis ?? _this.metadataEmphasis, metadataLink: metadataLink ?? _this.metadataLink, metadataLinkEmphasis: metadataLinkEmphasis ?? _this.metadataLinkEmphasis, + numericXl: numericXl ?? _this.numericXl, numericLg: numericLg ?? _this.numericLg, numericMd: numericMd ?? _this.numericMd, numericSm: numericSm ?? _this.numericSm, @@ -142,6 +145,7 @@ mixin _$StreamTextTheme { metadataLinkEmphasis: _this.metadataLinkEmphasis.merge( other.metadataLinkEmphasis, ), + numericXl: _this.numericXl.merge(other.numericXl), numericLg: _this.numericLg.merge(other.numericLg), numericMd: _this.numericMd.merge(other.numericMd), numericSm: _this.numericSm.merge(other.numericSm), @@ -176,6 +180,7 @@ mixin _$StreamTextTheme { _other.metadataEmphasis == _this.metadataEmphasis && _other.metadataLink == _this.metadataLink && _other.metadataLinkEmphasis == _this.metadataLinkEmphasis && + _other.numericXl == _this.numericXl && _other.numericLg == _this.numericLg && _other.numericMd == _this.numericMd && _other.numericSm == _this.numericSm; @@ -202,6 +207,7 @@ mixin _$StreamTextTheme { _this.metadataEmphasis, _this.metadataLink, _this.metadataLinkEmphasis, + _this.numericXl, _this.numericLg, _this.numericMd, _this.numericSm, 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 2ce5a6f..eaba5bb 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -6,6 +6,7 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann import '../factory/stream_component_factory.dart'; import 'components/stream_avatar_theme.dart'; +import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'primitives/stream_icons.dart'; @@ -83,6 +84,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamBoxShadow? boxShadow, // Components themes StreamAvatarThemeData? avatarTheme, + StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamComponentFactory? componentFactory, @@ -103,6 +105,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { // Components avatarTheme ??= const StreamAvatarThemeData(); + badgeCountTheme ??= const StreamBadgeCountThemeData(); buttonTheme ??= const StreamButtonThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -118,6 +121,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { textTheme: textTheme, boxShadow: boxShadow, avatarTheme: avatarTheme, + badgeCountTheme: badgeCountTheme, buttonTheme: buttonTheme, onlineIndicatorTheme: onlineIndicatorTheme, componentFactory: componentFactory, @@ -146,6 +150,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.textTheme, required this.boxShadow, required this.avatarTheme, + required this.badgeCountTheme, required this.buttonTheme, required this.onlineIndicatorTheme, required this.componentFactory, @@ -212,6 +217,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The avatar theme for this theme. final StreamAvatarThemeData avatarTheme; + /// The badge count theme for this theme. + final StreamBadgeCountThemeData badgeCountTheme; + /// The button theme for this theme. final StreamButtonThemeData buttonTheme; 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 36cb82f..7e6eb51 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 @@ -21,6 +21,7 @@ mixin _$StreamTheme on ThemeExtension { StreamTextTheme? textTheme, StreamBoxShadow? boxShadow, StreamAvatarThemeData? avatarTheme, + StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamComponentFactory? componentFactory, @@ -37,6 +38,7 @@ mixin _$StreamTheme on ThemeExtension { textTheme: textTheme ?? _this.textTheme, boxShadow: boxShadow ?? _this.boxShadow, avatarTheme: avatarTheme ?? _this.avatarTheme, + badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, componentFactory: componentFactory ?? _this.componentFactory, @@ -69,6 +71,11 @@ mixin _$StreamTheme on ThemeExtension { other.avatarTheme, t, )!, + badgeCountTheme: StreamBadgeCountThemeData.lerp( + _this.badgeCountTheme, + other.badgeCountTheme, + t, + )!, buttonTheme: t < 0.5 ? _this.buttonTheme : other.buttonTheme, onlineIndicatorTheme: StreamOnlineIndicatorThemeData.lerp( _this.onlineIndicatorTheme, @@ -103,6 +110,7 @@ mixin _$StreamTheme on ThemeExtension { _other.textTheme == _this.textTheme && _other.boxShadow == _this.boxShadow && _other.avatarTheme == _this.avatarTheme && + _other.badgeCountTheme == _this.badgeCountTheme && _other.buttonTheme == _this.buttonTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && _other.componentFactory == _this.componentFactory; @@ -123,6 +131,7 @@ mixin _$StreamTheme on ThemeExtension { _this.textTheme, _this.boxShadow, _this.avatarTheme, + _this.badgeCountTheme, _this.buttonTheme, _this.onlineIndicatorTheme, _this.componentFactory, 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 421523b..89c5a7d 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'components/stream_avatar_theme.dart'; +import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'primitives/stream_icons.dart'; @@ -60,6 +61,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamAvatarThemeData] from the nearest ancestor. StreamAvatarThemeData get streamAvatarTheme => StreamAvatarTheme.of(this); + /// Returns the [StreamBadgeCountThemeData] from the nearest ancestor. + StreamBadgeCountThemeData get streamBadgeCountTheme => StreamBadgeCountTheme.of(this); + /// Returns the [StreamButtonThemeData] from the nearest ancestor. StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this);