diff --git a/apps/design_system_gallery/README.md b/apps/design_system_gallery/README.md index a5791da..c50af4b 100644 --- a/apps/design_system_gallery/README.md +++ b/apps/design_system_gallery/README.md @@ -1,16 +1,39 @@ -# design_system_gallery +# Stream Design System Gallery -A new Flutter project. +Production Widgetbook app for documenting and validating `stream_core_flutter` +components and design tokens. -## Getting Started +## What This App Provides -This project is a starting point for a Flutter application. +- Interactive component use cases with knobs (Widgetbook). +- Foundation token showcases (primitives + semantic tokens). +- Theme Studio controls for live visual tuning. +- Device preview wrapper for realistic UI evaluation. -A few resources to get you started if this is your first Flutter project: +## Run Locally -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +```bash +cd apps/design_system_gallery +flutter pub get +dart run build_runner build --delete-conflicting-outputs +flutter run -d chrome +``` -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## Quality Checks + +```bash +cd apps/design_system_gallery +dart run build_runner build --delete-conflicting-outputs +dart format lib +flutter analyze +``` + +## Adding A New Component Showcase + +1. Create a use-case file in `lib/components//`. +2. Add `@widgetbook.UseCase(...)` entries (at minimum: Playground + Showcase). +3. Regenerate directories with `build_runner`. +4. Run format and analyze checks. + +The generated file `lib/app/gallery_app.directories.g.dart` is owned by +`widgetbook_generator` and should not be edited manually. 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 acaa5b8..48305f8 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 @@ -34,6 +34,10 @@ import 'package:design_system_gallery/components/common/stream_progress_bar.dart as _design_system_gallery_components_common_stream_progress_bar; import 'package:design_system_gallery/components/context_menu/stream_context_menu.dart' as _design_system_gallery_components_context_menu_stream_context_menu; +import 'package:design_system_gallery/components/controls/stream_emoji_chip.dart' + as _design_system_gallery_components_controls_stream_emoji_chip; +import 'package:design_system_gallery/components/controls/stream_emoji_chip_bar.dart' + as _design_system_gallery_components_controls_stream_emoji_chip_bar; import 'package:design_system_gallery/components/message_composer/message_composer.dart' as _design_system_gallery_components_message_composer_message_composer; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' @@ -44,6 +48,8 @@ import 'package:design_system_gallery/components/message_composer/message_compos as _design_system_gallery_components_message_composer_message_composer_attachment_reply; import 'package:design_system_gallery/components/reaction/picker/stream_reaction_picker_sheet.dart' as _design_system_gallery_components_reaction_picker_stream_reaction_picker_sheet; +import 'package:design_system_gallery/components/tiles/stream_list_tile.dart' + as _design_system_gallery_components_tiles_stream_list_tile; import 'package:design_system_gallery/primitives/colors.dart' as _design_system_gallery_primitives_colors; import 'package:design_system_gallery/primitives/icons.dart' @@ -397,6 +403,45 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Controls', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamEmojiChip', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_controls_stream_emoji_chip + .buildStreamEmojiChipPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_controls_stream_emoji_chip + .buildStreamEmojiChipShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamEmojiChipBar', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_controls_stream_emoji_chip_bar + .buildStreamEmojiChipBarPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_controls_stream_emoji_chip_bar + .buildStreamEmojiChipBarShowcase, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Message Composer', children: [ @@ -468,6 +513,28 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Tiles', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamListTile', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_tiles_stream_list_tile + .buildStreamListTilePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_tiles_stream_list_tile + .buildStreamListTileShowcase, + ), + ], + ), + ], + ), ], ), ]; diff --git a/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart b/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart new file mode 100644 index 0000000..dfeaf75 --- /dev/null +++ b/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart @@ -0,0 +1,759 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:unicode_emojis/unicode_emojis.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamEmojiChip, + path: '[Components]/Controls', +) +Widget buildStreamEmojiChipPlayground(BuildContext context) { + return const _PlaygroundDemo(); +} + +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); + + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} + +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + var _isSelected = false; + + @override + Widget build(BuildContext context) { + final isAddEmojiType = context.knobs.boolean( + label: 'Add Emoji Type', + description: 'Switches to the add-reaction icon variant (no emoji or count).', + ); + + final emoji = isAddEmojiType + ? null + : context.knobs.object.dropdown( + label: 'Emoji', + options: _sampleEmojis, + initialOption: _sampleEmojis.first, + labelBuilder: (e) => '${e.emoji} ${e.name}', + description: 'The emoji to display.', + ); + + final showCount = + !isAddEmojiType && + context.knobs.boolean( + label: 'Show Count', + initialValue: true, + description: 'Whether to show the reaction count label.', + ); + + final count = (!isAddEmojiType && showCount) + ? context.knobs.int.slider( + label: 'Count', + initialValue: 1, + min: 1, + max: 99, + description: 'The reaction count to display.', + ) + : null; + + final isDisabled = context.knobs.boolean( + label: 'Disabled', + description: 'Whether the chip is disabled (non-interactive).', + ); + + final showLongPress = context.knobs.boolean( + label: 'Long Press', + description: 'Whether long-press is handled (e.g. to open a skin-tone picker).', + ); + + void onPressed() { + setState(() => _isSelected = !_isSelected); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(_isSelected ? 'Reaction added' : 'Reaction removed'), + duration: const Duration(seconds: 1), + ), + ); + } + + void onLongPressed() { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Long pressed — e.g. open skin-tone picker'), + duration: Duration(seconds: 1), + ), + ); + } + + return Center( + child: isAddEmojiType + ? StreamEmojiChip.addEmoji( + onPressed: isDisabled ? null : onPressed, + onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, + ) + : StreamEmojiChip( + emoji: Text(emoji!.emoji), + count: count, + isSelected: _isSelected, + onPressed: isDisabled ? null : onPressed, + onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamEmojiChip, + path: '[Components]/Controls', +) +Widget buildStreamEmojiChipShowcase(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, + spacing: spacing.xl, + children: const [ + _TypeVariantsSection(), + _CountValuesSection(), + _StateMatrixSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Type Variants Section +// ============================================================================= + +class _TypeVariantsSection extends StatelessWidget { + const _TypeVariantsSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'TYPE VARIANTS'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + 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.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + 'Standard chip (with count, without count, selected) and Add Emoji chip.', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + Wrap( + spacing: spacing.md, + runSpacing: spacing.md, + children: [ + _TypeDemo( + label: 'With count', + child: StreamEmojiChip( + emoji: const Text('👍'), + count: 3, + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'Without count', + child: StreamEmojiChip( + emoji: const Text('👍'), + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'Selected', + child: StreamEmojiChip( + emoji: const Text('👍'), + count: 3, + isSelected: true, + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'Add Emoji', + child: StreamEmojiChip.addEmoji(onPressed: () {}), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _TypeDemo extends StatelessWidget { + const _TypeDemo({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: [ + child, + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Count Values Section +// ============================================================================= + +class _CountValuesSection extends StatelessWidget { + const _CountValuesSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'COUNT VALUES'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + 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.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + "Chips respect a minimum width so single-digit counts don't produce " + 'a narrow pill. Large counts expand the chip naturally.', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + Wrap( + spacing: spacing.sm, + runSpacing: spacing.sm, + children: [ + for (final count in [1, 9, 42, 99]) + _TypeDemo( + label: 'count: $count', + child: StreamEmojiChip( + emoji: const Text('👍'), + count: count, + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'no count', + child: StreamEmojiChip(emoji: const Text('👍')), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// State Matrix Section +// ============================================================================= + +class _StateMatrixSection extends StatelessWidget { + const _StateMatrixSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'STATE MATRIX'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + 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.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + 'Hover and press states are interactive — try them. ' + 'Selected state applies only to the standard chip.', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + // Header + Row( + children: [ + const SizedBox(width: 88), + Expanded( + child: Center( + child: Text( + 'Standard', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ), + ), + ), + ), + Expanded( + child: Center( + child: Text( + 'Add Emoji', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ), + ), + ), + ), + ], + ), + _StateRow( + stateLabel: 'default', + standardChip: StreamEmojiChip( + emoji: const Text('👍'), + count: 3, + onPressed: () {}, + ), + addEmojiChip: StreamEmojiChip.addEmoji(onPressed: () {}), + ), + _StateRow( + stateLabel: 'selected', + standardChip: StreamEmojiChip( + emoji: const Text('👍'), + count: 3, + isSelected: true, + onPressed: () {}, + ), + addEmojiChip: null, // selection not applicable for addEmoji + ), + _StateRow( + stateLabel: 'disabled', + standardChip: StreamEmojiChip( + emoji: const Text('👍'), + count: 3, + ), + addEmojiChip: StreamEmojiChip.addEmoji(), + ), + _StateRow( + stateLabel: 'selected\n+ disabled', + standardChip: StreamEmojiChip( + emoji: const Text('👍'), + count: 3, + isSelected: true, + ), + addEmojiChip: null, + ), + ], + ), + ), + ], + ); + } +} + +class _StateRow extends StatelessWidget { + const _StateRow({ + required this.stateLabel, + required this.standardChip, + required this.addEmojiChip, + }); + + final String stateLabel; + final Widget standardChip; + final Widget? addEmojiChip; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Row( + children: [ + SizedBox( + width: 88, + child: Text( + stateLabel, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + fontSize: 10, + ), + ), + ), + Expanded(child: Center(child: standardChip)), + Expanded( + child: Center( + child: + addEmojiChip ?? + Text( + 'n/a', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textDisabled, + fontSize: 10, + ), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Real-World Section +// ============================================================================= + +class _RealWorldSection extends StatelessWidget { + const _RealWorldSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'REAL-WORLD EXAMPLES'), + _ExampleCard( + title: 'Message Reactions', + description: + 'Interactive reaction bar below a chat message — tap to toggle, ' + 'long-press would open a skin-tone picker.', + child: _MessageReactionsExample(), + ), + _ExampleCard( + title: 'Busy Reaction Bar', + description: + 'Many reactions with large counts — shows wrap behaviour and ' + 'minimum width enforcement.', + child: _BusyReactionsExample(), + ), + ], + ); + } +} + +class _MessageReactionsExample extends StatefulWidget { + const _MessageReactionsExample(); + + @override + State<_MessageReactionsExample> createState() => _MessageReactionsExampleState(); +} + +class _MessageReactionsExampleState extends State<_MessageReactionsExample> { + final _counts = {'👍': 3, '❤️': 1, '😂': 5}; + final _mine = {'👍'}; + + void _toggle(String emoji) { + setState(() { + if (_mine.contains(emoji)) { + _mine.remove(emoji); + _counts[emoji] = (_counts[emoji] ?? 1) - 1; + if (_counts[emoji]! <= 0) _counts.remove(emoji); + } else { + _mine.add(emoji); + _counts[emoji] = (_counts[emoji] ?? 0) + 1; + } + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 280), + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.sm), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Text( + 'Looks great! 🎉 Really happy with how this turned out.', + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + ), + ), + Wrap( + spacing: spacing.xs, + runSpacing: spacing.xs, + children: [ + for (final entry in _counts.entries) + StreamEmojiChip( + emoji: Text(entry.key), + count: entry.value, + isSelected: _mine.contains(entry.key), + onPressed: () => _toggle(entry.key), + onLongPress: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Long pressed — open skin-tone picker'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + StreamEmojiChip.addEmoji( + onPressed: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Open reaction picker'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + ], + ); + } +} + +class _BusyReactionsExample extends StatelessWidget { + const _BusyReactionsExample(); + + static const _reactions = [ + ('👍', 42), + ('❤️', 99), + ('😂', 17), + ('🔥', 8), + ('😮', 3), + ('👏', 26), + ('🎉', 1), + ('😢', 5), + ]; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Wrap( + spacing: spacing.xs, + runSpacing: spacing.xs, + children: [ + for (final (emoji, count) in _reactions) + StreamEmojiChip( + emoji: Text(emoji), + count: count, + onPressed: () {}, + ), + StreamEmojiChip.addEmoji(onPressed: () {}), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +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.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: 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.borderSubtle), + Container( + width: double.infinity, + padding: EdgeInsets.all(spacing.md), + color: colorScheme.backgroundSurfaceSubtle, + child: child, + ), + ], + ), + ); + } +} + +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, + ), + ), + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +Emoji _byName(String name) => UnicodeEmojis.allEmojis.firstWhere((e) => e.name == name); + +final _sampleEmojis = [ + _byName('thumbs up sign'), + _byName('heavy black heart'), + _byName('face with tears of joy'), + _byName('fire'), + _byName('clapping hands sign'), + _byName('party popper'), + _byName('white heavy check mark'), + _byName('rocket'), +]; diff --git a/apps/design_system_gallery/lib/components/controls/stream_emoji_chip_bar.dart b/apps/design_system_gallery/lib/components/controls/stream_emoji_chip_bar.dart new file mode 100644 index 0000000..6e517fe --- /dev/null +++ b/apps/design_system_gallery/lib/components/controls/stream_emoji_chip_bar.dart @@ -0,0 +1,681 @@ +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: StreamEmojiChipBar, + path: '[Components]/Controls', +) +Widget buildStreamEmojiChipBarPlayground(BuildContext context) { + return const _PlaygroundDemo(); +} + +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); + + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} + +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + late List> _items; + String? _selected; + + @override + void initState() { + super.initState(); + _items = _buildItems(5); + } + + List> _buildItems(int count) { + return [ + for (var i = 0; i < count && i < _reactions.length; i++) + StreamEmojiChipItem( + value: _reactions[i].$1, + emoji: Text(_reactions[i].$1), + count: _reactions[i].$2, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final itemCount = context.knobs.int.slider( + label: 'Item Count', + initialValue: 5, + min: 1, + max: 8, + description: 'Number of emoji filter items.', + ); + + final showLeading = context.knobs.boolean( + label: 'Show Leading (Add Emoji)', + initialValue: true, + description: 'Whether to show the add-emoji chip before items.', + ); + + final isDisabled = context.knobs.boolean( + label: 'Disabled', + description: 'Whether the chip bar is non-interactive.', + ); + + if (itemCount != _items.length) { + _items = _buildItems(itemCount); + final values = _items.map((e) => e.value).toSet(); + if (_selected != null && !values.contains(_selected)) { + _selected = null; + } + } + + return Center( + child: StreamEmojiChipBar( + leading: showLeading + ? StreamEmojiChip.addEmoji( + onPressed: isDisabled + ? null + : () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Open reaction picker'), + duration: Duration(seconds: 1), + ), + ); + }, + ) + : null, + items: _items, + selected: _selected, + onSelected: isDisabled + ? null + : (value) { + setState(() => _selected = value); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + value == null ? 'Filter cleared (All)' : 'Filter: $value', + ), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamEmojiChipBar, + path: '[Components]/Controls', +) +Widget buildStreamEmojiChipBarShowcase(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, + spacing: spacing.xl, + children: const [ + _VariantsSection(), + _SelectionStatesSection(), + _LayoutSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Variants Section +// ============================================================================= + +class _VariantsSection extends StatelessWidget { + const _VariantsSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'VARIANTS'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.symmetric(vertical: spacing.md), + 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.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Text( + 'With leading add-emoji chip, without, and disabled states.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + _VariantDemo( + label: 'With leading', + child: StreamEmojiChipBar( + leading: StreamEmojiChip.addEmoji(onPressed: () {}), + items: _sampleItems, + onSelected: (_) {}, + ), + ), + _VariantDemo( + label: 'Without leading', + child: StreamEmojiChipBar( + items: _sampleItems, + onSelected: (_) {}, + ), + ), + _VariantDemo( + label: 'With selection', + child: StreamEmojiChipBar( + leading: StreamEmojiChip.addEmoji(onPressed: () {}), + items: _sampleItems, + selected: '👍', + onSelected: (_) {}, + ), + ), + _VariantDemo( + label: 'Disabled', + child: StreamEmojiChipBar( + leading: StreamEmojiChip.addEmoji(), + items: _sampleItems, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _VariantDemo extends StatelessWidget { + const _VariantDemo({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ), + child, + ], + ); + } +} + +// ============================================================================= +// Selection States Section +// ============================================================================= + +class _SelectionStatesSection extends StatelessWidget { + const _SelectionStatesSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'SELECTION STATES'), + _ExampleCard( + title: 'Toggle Selection', + description: + 'Tap a chip to select it. Tap again to deselect. ' + 'Only one chip can be selected at a time.', + child: _ToggleSelectionDemo(), + ), + ], + ); + } +} + +class _ToggleSelectionDemo extends StatefulWidget { + const _ToggleSelectionDemo(); + + @override + State<_ToggleSelectionDemo> createState() => _ToggleSelectionDemoState(); +} + +class _ToggleSelectionDemoState extends State<_ToggleSelectionDemo> { + String? _selected; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.sm, + children: [ + StreamEmojiChipBar( + leading: StreamEmojiChip.addEmoji(onPressed: () {}), + items: _sampleItems, + selected: _selected, + onSelected: (value) => setState(() => _selected = value), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Text( + _selected == null ? 'No filter active — showing all reactions' : 'Filtering by $_selected', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Layout Section +// ============================================================================= + +class _LayoutSection extends StatelessWidget { + const _LayoutSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'LAYOUT'), + _ExampleCard( + title: 'Overflow Scrolling', + description: + 'When chips overflow the available width, the bar scrolls ' + 'horizontally. Swipe to reveal more.', + child: _OverflowScrollDemo(), + ), + _ExampleCard( + title: 'Custom Spacing', + description: 'Custom padding and spacing between chips.', + child: _CustomSpacingDemo(), + ), + ], + ); + } +} + +class _OverflowScrollDemo extends StatelessWidget { + const _OverflowScrollDemo(); + + @override + Widget build(BuildContext context) { + return StreamEmojiChipBar( + leading: StreamEmojiChip.addEmoji(onPressed: () {}), + items: _manyItems, + onSelected: (_) {}, + ); + } +} + +class _CustomSpacingDemo extends StatelessWidget { + const _CustomSpacingDemo(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + _VariantDemo( + label: 'spacing: 16', + child: StreamEmojiChipBar( + items: _sampleItems, + spacing: 16, + onSelected: (_) {}, + ), + ), + _VariantDemo( + label: 'spacing: 2', + child: StreamEmojiChipBar( + items: _sampleItems, + spacing: 2, + onSelected: (_) {}, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Real-World Section +// ============================================================================= + +class _RealWorldSection extends StatelessWidget { + const _RealWorldSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'REAL-WORLD EXAMPLES'), + _ExampleCard( + title: 'Reaction Detail Sheet', + description: + 'Filter bar above a user list — simulates the reaction detail ' + 'sheet. Tap a chip to filter, tap again to show all.', + child: _ReactionDetailExample(), + ), + ], + ); + } +} + +class _ReactionDetailExample extends StatefulWidget { + const _ReactionDetailExample(); + + @override + State<_ReactionDetailExample> createState() => _ReactionDetailExampleState(); +} + +class _ReactionDetailExampleState extends State<_ReactionDetailExample> { + String? _selected; + + static const _reactionUsers = [ + ('👍', ['Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace']), + ('❤️', ['Alice', 'Carol', 'Eve', 'Grace', 'Ivy']), + ('😂', ['Bob', 'Dan', 'Frank']), + ('🔥', ['Carol', 'Eve']), + ('😮', ['Dan']), + ]; + + late final _items = [ + for (final (emoji, users) in _reactionUsers) + StreamEmojiChipItem( + value: emoji, + emoji: Text(emoji), + count: users.length, + ), + ]; + + List<(String emoji, String user)> get _filteredUsers { + if (_selected == null) { + return [ + for (final (emoji, users) in _reactionUsers) + for (final user in users) (emoji, user), + ]; + } + final (emoji, users) = _reactionUsers.firstWhere((r) => r.$1 == _selected); + return [for (final user in users) (emoji, user)]; + } + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + final filtered = _filteredUsers; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Text( + filtered.length == 1 ? '1 Reaction' : '${filtered.length} Reactions', + style: textTheme.headingSm, + textAlign: TextAlign.center, + ), + ), + SizedBox(height: spacing.sm), + StreamEmojiChipBar( + leading: StreamEmojiChip.addEmoji(onPressed: () {}), + items: _items, + selected: _selected, + onSelected: (value) => setState(() => _selected = value), + ), + SizedBox(height: spacing.xs), + for (final (emoji, user) in filtered) _ReactionUserTile(emoji: emoji, userName: user), + ], + ); + } +} + +class _ReactionUserTile extends StatelessWidget { + const _ReactionUserTile({required this.emoji, required this.userName}); + + final String emoji; + final String userName; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxs, + ), + child: Row( + spacing: spacing.sm, + children: [ + StreamAvatar( + size: StreamAvatarSize.md, + placeholder: (_) => Text(userName[0]), + ), + Expanded( + child: Text( + userName, + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + StreamEmoji( + size: StreamEmojiSize.sm, + emoji: Text(emoji), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +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.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: 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.borderSubtle), + Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: spacing.md), + color: colorScheme.backgroundSurfaceSubtle, + child: child, + ), + ], + ), + ); + } +} + +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, + ), + ), + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +const _reactions = [ + ('👍', 7), + ('❤️', 5), + ('😂', 3), + ('🔥', 2), + ('😮', 1), + ('👏', 12), + ('🎉', 4), + ('😢', 2), +]; + +final _sampleItems = [ + for (final (emoji, count) in _reactions.take(5)) StreamEmojiChipItem(value: emoji, emoji: Text(emoji), count: count), +]; + +final _manyItems = [ + for (final (emoji, count) in _reactions) StreamEmojiChipItem(value: emoji, emoji: Text(emoji), count: count), +]; diff --git a/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart b/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart index 8d125bb..9bf2424 100644 --- a/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart +++ b/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:unicode_emojis/unicode_emojis.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @@ -21,25 +22,73 @@ Widget buildStreamReactionPickerSheetDefault(BuildContext context) { description: 'The size of each reaction button in the grid.', ); - return Center( - child: StreamButton( - label: 'Show Reaction Picker', - onTap: () async { - final emoji = await StreamReactionPickerSheet.show( - context: context, - reactionButtonSize: reactionButtonSize, - ); - if (emoji != null && context.mounted) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text('Selected ${emoji.emoji} ${emoji.name}'), - duration: const Duration(seconds: 2), + return _ReactionPickerPlayground(reactionButtonSize: reactionButtonSize); +} + +class _ReactionPickerPlayground extends StatefulWidget { + const _ReactionPickerPlayground({required this.reactionButtonSize}); + + final StreamEmojiButtonSize reactionButtonSize; + + @override + State<_ReactionPickerPlayground> createState() => _ReactionPickerPlaygroundState(); +} + +class _ReactionPickerPlaygroundState extends State<_ReactionPickerPlayground> { + final _selectedReactions = {}; + + void _toggle(Emoji emoji) { + setState(() { + if (_selectedReactions.containsKey(emoji.shortName)) { + _selectedReactions.remove(emoji.shortName); + } else { + _selectedReactions[emoji.shortName] = emoji; + } + }); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: spacing.md, + children: [ + StreamButton( + label: 'Show Reaction Picker', + onTap: () async { + final emoji = await StreamReactionPickerSheet.show( + context: context, + reactionButtonSize: widget.reactionButtonSize, + selectedReactions: _selectedReactions.keys.toSet(), + ); + if (emoji != null && context.mounted) _toggle(emoji); + }, + ), + if (_selectedReactions.isNotEmpty) ...[ + Text( + 'Selected Reactions', + style: textTheme.headingXs.copyWith( + color: colorScheme.textSecondary, ), - ); - } - }, - ), - ); + ), + Wrap( + spacing: spacing.xs, + runSpacing: spacing.xs, + children: [ + for (final entry in _selectedReactions.entries) + StreamEmoji( + emoji: Text(entry.value.emoji), + ), + ], + ), + ], + ], + ), + ); + } } diff --git a/apps/design_system_gallery/lib/components/tiles/stream_list_tile.dart b/apps/design_system_gallery/lib/components/tiles/stream_list_tile.dart new file mode 100644 index 0000000..6432be3 --- /dev/null +++ b/apps/design_system_gallery/lib/components/tiles/stream_list_tile.dart @@ -0,0 +1,539 @@ +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: StreamListTile, + path: '[Components]/Tiles', +) +Widget buildStreamListTilePlayground(BuildContext context) { + return const _PlaygroundDemo(); +} + +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); + + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} + +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + @override + Widget build(BuildContext context) { + final title = context.knobs.string( + label: 'Title', + initialValue: 'Alice Johnson', + description: 'Primary label shown in the tile.', + ); + + final subtitleText = context.knobs.stringOrNull( + label: 'Subtitle', + initialValue: 'Online now', + description: 'Optional secondary text shown below title.', + ); + + final descriptionText = context.knobs.stringOrNull( + label: 'Description', + initialValue: '2m', + description: 'Optional right-side metadata text (e.g. timestamp).', + ); + + final enabled = context.knobs.boolean( + label: 'Enabled', + initialValue: true, + description: 'Whether the tile is interactive.', + ); + + final selected = context.knobs.boolean( + label: 'Selected', + description: 'Applies selected colors and selected background.', + ); + + final showLeading = context.knobs.boolean( + label: 'Leading', + initialValue: true, + description: 'Show a leading avatar.', + ); + + final showTrailing = context.knobs.boolean( + label: 'Trailing', + initialValue: true, + description: 'Show a trailing chevron icon.', + ); + + void onTap() { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Tapped'), + duration: Duration(seconds: 1), + ), + ); + } + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Material( + type: MaterialType.transparency, + child: StreamListTile( + leading: showLeading ? _avatar('AJ') : null, + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: (subtitleText?.isNotEmpty ?? false) + ? Text(subtitleText!, maxLines: 1, overflow: TextOverflow.ellipsis) + : null, + description: (descriptionText?.isNotEmpty ?? false) + ? Text(descriptionText!, maxLines: 1, overflow: TextOverflow.ellipsis) + : null, + trailing: showTrailing ? const Icon(Icons.chevron_right_rounded) : null, + selected: selected, + enabled: enabled, + onTap: enabled ? onTap : null, + ), + ), + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamListTile, + path: '[Components]/Tiles', +) +Widget buildStreamListTileShowcase(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, + spacing: spacing.xl, + children: const [ + _StatesSection(), + _LayoutPatternsSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// States Section +// ============================================================================= + +class _StatesSection extends StatelessWidget { + const _StatesSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'STATES'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + 'Tap any tile to see the pressed overlay. ' + 'Disabled tiles block all interaction.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + Column( + spacing: spacing.xs, + children: const [ + _StateRow( + label: 'Default', + subtitle: 'Enabled, not selected', + tile: _DemoTile(), + ), + _StateRow( + label: 'Selected', + subtitle: 'Selected foreground and background', + tile: _DemoTile(selected: true), + ), + _StateRow( + label: 'Disabled', + subtitle: 'Non-interactive, muted colors', + tile: _DemoTile(enabled: false), + ), + _StateRow( + label: 'Disabled + Selected', + subtitle: 'Selected layout with disabled interaction', + tile: _DemoTile(enabled: false, selected: true), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Layout Patterns Section +// ============================================================================= + +class _LayoutPatternsSection extends StatelessWidget { + const _LayoutPatternsSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'LAYOUT PATTERNS'), + Column( + spacing: 8, + children: [ + _PatternCard( + title: 'Title Only', + tile: _DemoTile(subtitle: null, description: null), + ), + _PatternCard( + title: 'Title + Subtitle', + tile: _DemoTile(description: null), + ), + _PatternCard( + title: 'Title + Description', + tile: _DemoTile(subtitle: null), + ), + _PatternCard( + title: 'Full Composition', + tile: _DemoTile(), + ), + ], + ), + ], + ); + } +} + +// ============================================================================= +// Real-World Section +// ============================================================================= + +class _RealWorldSection extends StatelessWidget { + const _RealWorldSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'REAL-WORLD EXAMPLE'), + _ExampleCard( + title: 'Conversation List', + description: + 'Tap a row to toggle its selected state, mimicking a real ' + 'conversation list where the active channel is highlighted.', + child: _ConversationListExample(), + ), + ], + ); + } +} + +class _ConversationListExample extends StatefulWidget { + const _ConversationListExample(); + + @override + State<_ConversationListExample> createState() => _ConversationListExampleState(); +} + +class _ConversationListExampleState extends State<_ConversationListExample> { + static const _items = [ + ('Alice Johnson', 'See you in 10?', '2m'), + ('Mobile Team', 'Design review starts in 5m', '5m'), + ('Product Updates', 'Quarterly roadmap posted', '45m'), + ('Support', 'Ticket #8452 has been resolved', '1h'), + ]; + + var _selectedIndex = 0; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + children: [ + for (var i = 0; i < _items.length; i++) ...[ + Material( + type: MaterialType.transparency, + child: StreamListTile( + leading: _avatar(_items[i].$1.substring(0, 2).toUpperCase()), + title: Text(_items[i].$1, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(_items[i].$2, maxLines: 1, overflow: TextOverflow.ellipsis), + description: Text(_items[i].$3), + trailing: const Icon(Icons.chevron_right_rounded), + selected: i == _selectedIndex, + onTap: () => setState(() => _selectedIndex = i), + ), + ), + if (i < _items.length - 1) Divider(height: 1, color: colorScheme.borderSubtle), + ], + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _StateRow extends StatelessWidget { + const _StateRow({ + required this.label, + required this.subtitle, + required this.tile, + }); + + final String label; + final String subtitle; + final Widget tile; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), + ), + Text( + subtitle, + style: textTheme.metadataDefault.copyWith(color: colorScheme.textTertiary), + ), + SizedBox(height: spacing.xs), + tile, + ], + ); + } +} + +class _PatternCard extends StatelessWidget { + const _PatternCard({required this.title, required this.tile}); + + final String title; + final Widget tile; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.sm), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textSecondary), + ), + SizedBox(height: spacing.xs), + tile, + ], + ), + ); + } +} + +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.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: 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.borderSubtle), + child, + ], + ), + ); + } +} + +class _DemoTile extends StatelessWidget { + const _DemoTile({ + this.enabled = true, + this.selected = false, + this.subtitle = 'Online now', + this.description = '2m', + }); + + final bool enabled; + final bool selected; + final String? subtitle; + final String? description; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: StreamListTile( + leading: _avatar('AJ'), + title: const Text('Alice Johnson', maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: subtitle == null ? null : Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis), + description: description == null ? null : Text(description!, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: const Icon(Icons.chevron_right_rounded), + enabled: enabled, + selected: selected, + onTap: enabled ? () {} : null, + ), + ); + } +} + +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, + ), + ), + ); + } +} + +Widget _avatar(String initials) { + return StreamAvatar( + placeholder: (_) => Text(initials), + ); +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/baselines_toggle.dart b/apps/design_system_gallery/lib/widgets/toolbar/baselines_toggle.dart new file mode 100644 index 0000000..d544abc --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/baselines_toggle.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'toolbar_button.dart'; + +/// Debug baselines toggle button for visualizing text baselines. +class BaselinesToggle extends StatefulWidget { + const BaselinesToggle({super.key}); + + @override + State createState() => _BaselinesToggleState(); +} + +class _BaselinesToggleState extends State { + void _toggle() { + setState(() => debugPaintBaselinesEnabled = !debugPaintBaselinesEnabled); + WidgetsBinding.instance.performReassemble(); + } + + @override + Widget build(BuildContext context) { + return ToolbarButton( + icon: Icons.format_line_spacing, + tooltip: 'Text Baselines', + isActive: debugPaintBaselinesEnabled, + onTap: _toggle, + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart index abce4f8..564f660 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 'package:svg_icon_widget/svg_icon_widget.dart'; import '../../config/preview_configuration.dart'; import '../../config/theme_configuration.dart'; import '../../core/stream_icons.dart'; +import 'baselines_toggle.dart'; import 'debug_paint_toggle.dart'; import 'device_selector.dart'; import 'platform_selector.dart'; @@ -101,6 +102,7 @@ class GalleryToolbar extends StatelessWidget { // Debug tools (debug mode only) if (kDebugMode) ...[ const DebugPaintToggle(), + const BaselinesToggle(), const WidgetSelectToggle(), ], ], diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 13fda19..a28c115 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -12,7 +12,10 @@ export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; export 'components/common/stream_progress_bar.dart' hide DefaultStreamProgressBar; export 'components/context_menu/stream_context_menu.dart'; export 'components/context_menu/stream_context_menu_action.dart' hide DefaultStreamContextMenuAction; +export 'components/controls/stream_emoji_chip.dart' hide DefaultStreamEmojiChip; +export 'components/controls/stream_emoji_chip_bar.dart' hide DefaultStreamEmojiChipBar; export 'components/controls/stream_remove_control.dart'; +export 'components/list/stream_list_tile.dart' hide DefaultStreamListTile; export 'components/message_composer.dart'; export 'components/reaction/picker/stream_reaction_picker_sheet.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart index f9d24e7..fe04575 100644 --- a/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart @@ -37,12 +37,13 @@ enum StreamEmojiSize { /// A widget that displays an emoji or icon at a consistent size. /// -/// [StreamEmoji] renders emoji characters or icon widgets within a fixed -/// square container. It handles platform-specific emoji font fallbacks, -/// prevents text scaling, and ensures emoji render without clipping. +/// [StreamEmoji] renders emoji characters or icon widgets at a requested +/// logical-pixel size. It applies platform-appropriate emoji font fallbacks, +/// disables text scaling, and locks the line height to the font size so the +/// emoji glyph occupies exactly the requested area. /// /// The widget accepts any [Widget] as the [emoji] parameter, making it -/// suitable for both Unicode emoji text and Material Icons. +/// suitable for both Unicode emoji text and [Icon] widgets. /// /// {@tool snippet} /// @@ -70,7 +71,7 @@ enum StreamEmojiSize { /// /// {@tool snippet} /// -/// Default size (uses IconTheme or medium): +/// Default size (uses [IconTheme] size or [StreamEmojiSize.md]): /// /// ```dart /// StreamEmoji(emoji: Text('🔥')) @@ -79,20 +80,20 @@ enum StreamEmojiSize { /// /// {@tool snippet} /// -/// Use with IconButton (size controlled via iconSize): +/// Use with [IconButton] (size controlled via [IconButton.iconSize]): /// /// ```dart /// IconButton( -/// iconSize: 32, // Size applied to StreamEmoji via IconTheme +/// iconSize: 32, /// icon: StreamEmoji(emoji: Text('👍')), /// onPressed: () {}, /// ) /// ``` /// {@end-tool} /// -/// **Best Practice:** When using `StreamEmoji` inside an `IconButton`, set the -/// size using `IconButton.iconSize` instead of `StreamEmoji.size`. The emoji -/// will automatically inherit the size from the button's `IconTheme`. +/// **Best Practice:** When using [StreamEmoji] inside an [IconButton], set the +/// size using [IconButton.iconSize] instead of [StreamEmoji.size]. The emoji +/// inherits the size automatically from the ambient [IconTheme]. /// /// See also: /// @@ -132,10 +133,10 @@ class StreamEmojiProps { required this.emoji, }); - /// The size of the emoji container. + /// The display size of the emoji. /// - /// If null, uses [IconTheme.of(context).size] if available, - /// otherwise defaults to [StreamEmojiSize.md] (24px). + /// If null, the size is resolved from the ambient [IconTheme] size. If that + /// is also null, [StreamEmojiSize.md] (24px) is used. final StreamEmojiSize? size; /// The emoji or icon widget to display. @@ -169,33 +170,24 @@ class DefaultStreamEmoji extends StatelessWidget { final iconTheme = IconTheme.of(context); final effectiveSize = props.size?.value ?? iconTheme.size ?? StreamEmojiSize.md.value; - return SizedBox( - width: effectiveSize, - height: effectiveSize, - child: Center( - child: MediaQuery.withNoTextScaling( - child: FittedBox( - fit: .scaleDown, - child: DefaultTextStyle.merge( - textAlign: .center, - style: TextStyle( - height: 1, - decoration: .none, - fontSize: effectiveSize, - // Commonly available fallback fonts for emoji rendering. - fontFamilyFallback: const [ - 'Apple Color Emoji', // iOS and macOS. - 'Noto Color Emoji', // Android, ChromeOS, Ubuntu, Linux. - 'Segoe UI Emoji', // Windows. - ], - ), - textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, - applyHeightToLastDescent: false, - ), - child: props.emoji, - ), + return SizedBox.square( + dimension: effectiveSize, + child: MediaQuery.withNoTextScaling( + child: DefaultTextStyle.merge( + textAlign: .center, + style: TextStyle( + height: 1, + decoration: .none, + textBaseline: .alphabetic, + fontSize: effectiveSize, + // Commonly available fallback fonts for emoji rendering. + fontFamilyFallback: const [ + 'Apple Color Emoji', // iOS and macOS. + 'Noto Color Emoji', // Android, ChromeOS, Ubuntu, Linux. + 'Segoe UI Emoji', // Windows. + ], ), + child: props.emoji, ), ), ); diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart index 00776d4..6ac3325 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart @@ -166,6 +166,8 @@ class DefaultStreamEmojiButton extends StatelessWidget { iconSize: emojiSize.value, icon: StreamEmoji(emoji: props.emoji), style: ButtonStyle( + tapTargetSize: .shrinkWrap, + visualDensity: .standard, fixedSize: .all(.square(effectiveSize.value)), minimumSize: .all(.square(effectiveSize.value)), maximumSize: .all(.square(effectiveSize.value)), diff --git a/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart b/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart new file mode 100644 index 0000000..395a06b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_emoji_chip_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/stream_theme_extensions.dart'; +import '../accessories/stream_emoji.dart'; + +/// A pill-shaped chip for displaying emoji reactions with an optional count. +/// +/// [StreamEmojiChip] renders an emoji alongside an optional reaction count. +/// Use [StreamEmojiChip.addEmoji] for the add-reaction button variant, which +/// shows the add-reaction icon instead. +/// +/// Both variants share the same theming and support hover, press, selected, +/// and disabled interaction states. +/// +/// {@tool snippet} +/// +/// Display a reaction chip: +/// +/// ```dart +/// StreamEmojiChip( +/// emoji: Text('👍'), +/// count: 3, +/// isSelected: true, +/// onPressed: () => toggleReaction('👍'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Display an add-reaction chip: +/// +/// ```dart +/// StreamEmojiChip.addEmoji( +/// onPressed: () => showReactionPicker(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiChipTheme], for customizing chip appearance. +/// * [StreamEmojiButton], for a circular emoji-only button. +class StreamEmojiChip extends StatelessWidget { + /// Creates an emoji count chip displaying [emoji] and an optional [count]. + /// + /// When [count] is null the count label is hidden. + /// When [onPressed] is null the chip is disabled. + StreamEmojiChip({ + super.key, + required Widget emoji, + int? count, + VoidCallback? onPressed, + VoidCallback? onLongPress, + bool isSelected = false, + }) : props = .new( + emoji: emoji, + count: count, + onPressed: onPressed, + onLongPress: onLongPress, + isSelected: isSelected, + ); + + /// Creates an add-emoji chip showing the add-reaction icon. + /// + /// When [onPressed] is null the chip is disabled. + static Widget addEmoji({ + Key? key, + VoidCallback? onPressed, + VoidCallback? onLongPress, + }) { + return StreamEmojiChipTheme( + // The add-reaction icon needs to be a bit bigger than the default + // emoji size to look visually balanced. + data: const .new(style: .new(emojiSize: 20)), + child: StreamEmojiChip( + key: key, + emoji: const _AddEmojiIcon(), + onPressed: onPressed, + onLongPress: onLongPress, + ), + ); + } + + /// The props controlling the appearance and behavior of this chip. + final StreamEmojiChipProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.maybeOf(context)?.emojiChip; + if (builder != null) return builder(context, props); + return DefaultStreamEmojiChip(props: props); + } +} + +/// Properties for configuring a [StreamEmojiChip]. +/// +/// This class holds all the configuration options for an emoji chip, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamEmojiChip], which uses these properties. +/// * [DefaultStreamEmojiChip], the default implementation. +class StreamEmojiChipProps { + /// Creates properties for an emoji chip. + const StreamEmojiChipProps({ + required this.emoji, + this.count, + this.onPressed, + this.onLongPress, + this.isSelected = false, + }); + + /// The emoji content to display inside the chip. + /// + /// Typically a [Text] widget containing a Unicode emoji character, e.g. + /// `Text('👍')`. The chip wraps this in a [StreamEmoji] internally to + /// ensure consistent sizing and platform-specific font fallbacks. + final Widget emoji; + + /// The reaction count to display next to [emoji]. + /// + /// When null the count label is hidden. + final int? count; + + /// Called when the chip is pressed. + /// + /// When null the chip is disabled. + final VoidCallback? onPressed; + + /// Called when the chip is long-pressed. + /// + /// Commonly used to open a skin-tone picker. + final VoidCallback? onLongPress; + + /// Whether the chip is in a selected state. + /// + /// When true the chip shows a selected background overlay. + final bool isSelected; +} + +/// Default implementation of [StreamEmojiChip]. +class DefaultStreamEmojiChip extends StatelessWidget { + /// Creates a default emoji chip. + const DefaultStreamEmojiChip({super.key, required this.props}); + + /// The props controlling the appearance and behavior of this chip. + final StreamEmojiChipProps props; + + @override + Widget build(BuildContext context) { + final chipThemeStyle = context.streamEmojiChipTheme.style; + final defaults = _StreamEmojiChipThemeDefaults(context); + + final effectiveBackgroundColor = chipThemeStyle?.backgroundColor ?? defaults.backgroundColor; + final effectiveForegroundColor = chipThemeStyle?.foregroundColor ?? defaults.foregroundColor; + final effectiveOverlayColor = chipThemeStyle?.overlayColor ?? defaults.overlayColor; + final effectiveTextStyle = chipThemeStyle?.textStyle ?? defaults.textStyle; + final effectiveEmojiSize = chipThemeStyle?.emojiSize ?? defaults.emojiSize; + final effectiveMinimumSize = chipThemeStyle?.minimumSize ?? defaults.minimumSize; + final effectiveMaximumSize = chipThemeStyle?.maximumSize ?? defaults.maximumSize; + final effectivePadding = chipThemeStyle?.padding ?? defaults.padding; + final effectiveShape = chipThemeStyle?.shape ?? defaults.shape; + final effectiveSide = chipThemeStyle?.side ?? defaults.side; + + return IconButton( + onPressed: props.onPressed, + onLongPress: props.onLongPress, + isSelected: props.isSelected, + iconSize: effectiveEmojiSize, + icon: _EmojiChipContent(emoji: props.emoji, count: props.count), + style: ButtonStyle( + tapTargetSize: .shrinkWrap, + visualDensity: .standard, + textStyle: effectiveTextStyle, + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + overlayColor: effectiveOverlayColor, + minimumSize: .all(effectiveMinimumSize), + maximumSize: .all(effectiveMaximumSize), + padding: .all(effectivePadding), + shape: .all(effectiveShape), + side: effectiveSide, + ), + ); + } +} + +// Internal widget to layout the emoji and count label inside the chip. +class _EmojiChipContent extends StatelessWidget { + const _EmojiChipContent({required this.emoji, this.count}); + + final Widget emoji; + final int? count; + + @override + Widget build(BuildContext context) { + // Need to disable text scaling here so that the text doesn't + // escape the chip when the textScaleFactor is large. + return MediaQuery.withNoTextScaling( + child: Row( + mainAxisSize: .min, + textBaseline: .alphabetic, + crossAxisAlignment: .baseline, + spacing: context.streamSpacing.xxs, + children: [ + StreamEmoji(emoji: emoji), + if (count case final count?) Text('$count'), + ], + ), + ); + } +} + +// Renders the add-reaction icon using the current theme's icon set. +class _AddEmojiIcon extends StatelessWidget { + const _AddEmojiIcon(); + + @override + Widget build(BuildContext context) => Icon(context.streamIcons.emojiAddReaction); +} + +// Provides default values for [StreamEmojiChipThemeStyle] based on +// the current [StreamColorScheme]. +class _StreamEmojiChipThemeDefaults extends StreamEmojiChipThemeStyle { + _StreamEmojiChipThemeDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + late final _radius = _context.streamRadius; + late final _spacing = _context.streamSpacing; + + @override + double get emojiSize => StreamEmojiSize.sm.value; + + @override + Size get minimumSize => const Size(64, 32); + + @override + Size get maximumSize => const Size.fromHeight(32); + + @override + WidgetStateProperty get backgroundColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + if (states.contains(WidgetState.selected)) { + return Color.alphaBlend(_colorScheme.stateSelected, StreamColors.transparent); + } + return StreamColors.transparent; + }); + + @override + WidgetStateProperty get foregroundColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textPrimary; + }); + + @override + WidgetStateProperty get overlayColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty get textStyle => .all( + _textTheme.bodyEmphasis.copyWith(fontFeatures: const [.tabularFigures()]), + ); + + @override + EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.sm, vertical: _spacing.xxs + _spacing.xxxs); + + @override + OutlinedBorder get shape => RoundedRectangleBorder(borderRadius: .all(_radius.max)); + + @override + WidgetStateBorderSide get side => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return BorderSide(color: _colorScheme.borderDisabled); + return BorderSide(color: _colorScheme.borderDefault); + }); +} diff --git a/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip_bar.dart b/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip_bar.dart new file mode 100644 index 0000000..4af237c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip_bar.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/stream_theme_extensions.dart'; +import 'stream_emoji_chip.dart'; + +/// A horizontally scrollable bar of [StreamEmojiChip]s for filtering by +/// reaction type. +/// +/// [StreamEmojiChipBar] renders a row of emoji chips where each +/// [StreamEmojiChipItem] represents a reaction type with its count. +/// Selection is value-based using [T] and supports toggle behavior: tapping +/// the already-selected item deselects it. +/// +/// The generic type [T] represents the value associated with each item +/// (e.g. a reaction type string, an enum, or even a [Widget]). Selection +/// comparison uses [T.==], so ensure [T] has meaningful equality semantics. +/// +/// An optional [leading] widget (typically [StreamEmojiChip.addEmoji]) is +/// rendered before the items inside the same scrollable row. +/// +/// To customize the appearance of chips inside the bar, wrap it with a +/// [StreamEmojiChipTheme]. +/// +/// {@tool snippet} +/// +/// Basic usage with `String` values: +/// +/// ```dart +/// StreamEmojiChipBar( +/// leading: StreamEmojiChip.addEmoji( +/// onPressed: () => showReactionPicker(), +/// ), +/// items: [ +/// StreamEmojiChipItem(value: '👍', emoji: Text('👍'), count: 7), +/// StreamEmojiChipItem(value: '❤️', emoji: Text('❤️'), count: 5), +/// ], +/// selected: _selectedReaction, +/// onSelected: (value) => setState(() => _selectedReaction = value), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiChipTheme], for customizing chip appearance inside the bar. +/// * [StreamEmojiChip], which renders each individual chip. +/// * [StreamEmojiChipItem], which describes a single filter item. +class StreamEmojiChipBar extends StatelessWidget { + /// Creates an emoji chip bar. + StreamEmojiChipBar({ + super.key, + Widget? leading, + required List> items, + T? selected, + ValueChanged? onSelected, + EdgeInsetsGeometry? padding, + double? spacing, + }) : props = .new( + leading: leading, + items: items, + selected: selected, + onSelected: onSelected, + padding: padding, + spacing: spacing, + ); + + /// The props controlling the appearance and behavior of this bar. + final StreamEmojiChipBarProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.maybeOf(context)?.emojiChipBar; + if (builder != null) return builder(context, props); + return DefaultStreamEmojiChipBar(props: props); + } +} + +/// Properties for configuring a [StreamEmojiChipBar]. +/// +/// This class holds all the configuration options for an emoji chip bar, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamEmojiChipBar], which uses these properties. +/// * [DefaultStreamEmojiChipBar], the default implementation. +class StreamEmojiChipBarProps { + /// Creates properties for an emoji chip bar. + const StreamEmojiChipBarProps({ + this.leading, + required this.items, + this.selected, + this.onSelected, + this.padding, + this.spacing, + }); + + /// An optional widget rendered before the filter items. + /// + /// Typically a [StreamEmojiChip.addEmoji] for adding new reactions. + /// Rendered inside the same scrollable row as the items. + final Widget? leading; + + /// The filter items to display. + /// + /// Each item renders as a [StreamEmojiChip] using [StreamEmojiChipItem.emoji] + /// and [StreamEmojiChipItem.count]. The bar manages [isSelected] and + /// [onPressed] internally. + final List> items; + + /// The currently selected value, or `null` if no filter is active. + /// + /// Compared against each [StreamEmojiChipItem.value] using [==]. + /// When `null`, no chip is highlighted (all reactions are shown). + final T? selected; + + /// Called when an item is tapped. + /// + /// Receives the tapped item's [value], or `null` when the already-selected + /// item is tapped again (toggle-off / deselect). When `null`, the bar is + /// non-interactive. + final ValueChanged? onSelected; + + /// The padding around the scrollable chip row. + /// + /// Falls back to horizontal `StreamSpacing.md`. + final EdgeInsetsGeometry? padding; + + /// The gap between chips in the row. + /// + /// Falls back to `StreamSpacing.xs` (8px). + final double? spacing; +} + +/// A single item in a [StreamEmojiChipBar]. +/// +/// Pairs a [value] of type [T] (used for selection identity) with visual +/// properties ([emoji] and optional [count]) rendered inside a +/// [StreamEmojiChip]. +/// +/// This follows the same pattern as [ButtonSegment] in Flutter's +/// [SegmentedButton], where the generic value drives selection and widget +/// fields drive rendering. +class StreamEmojiChipItem { + /// Creates a filter bar item. + const StreamEmojiChipItem({ + required this.value, + required this.emoji, + this.count, + }); + + /// The value this item represents. + /// + /// Used to match against [StreamEmojiChipBarProps.selected] via [==]. + /// For reaction filtering, this is typically a reaction type identifier. + final T value; + + /// The emoji content to display inside the chip. + /// + /// Typically a reaction icon builder result or a [Text] widget containing + /// a Unicode emoji character. + final Widget emoji; + + /// The reaction count to display next to [emoji]. + /// + /// When `null` the count label is hidden. + final int? count; +} + +/// Default implementation of [StreamEmojiChipBar]. +/// +/// Renders a horizontally scrollable [SingleChildScrollView] containing the +/// optional [StreamEmojiChipBarProps.leading] widget followed by a +/// [StreamEmojiChip] for each item. Handles toggle selection and +/// auto-scrolls to keep the selected item visible. +class DefaultStreamEmojiChipBar extends StatefulWidget { + /// Creates a default emoji chip bar. + const DefaultStreamEmojiChipBar({super.key, required this.props}); + + /// The props controlling the appearance and behavior of this bar. + final StreamEmojiChipBarProps props; + + @override + State> createState() => _DefaultStreamEmojiChipBarState(); +} + +class _DefaultStreamEmojiChipBarState extends State> { + late Map _valueKeys; + + StreamEmojiChipBarProps get props => widget.props; + + @override + void initState() { + super.initState(); + _valueKeys = {for (final item in props.items) item.value: GlobalKey()}; + } + + @override + void didUpdateWidget(DefaultStreamEmojiChipBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.props.items, props.items)) { + _valueKeys = {for (final item in props.items) item.value: GlobalKey()}; + } + if (widget.props.selected != oldWidget.props.selected) { + _scrollToSelected(); + } + } + + void _scrollToSelected() { + final selected = props.selected; + if (selected == null) return; + + final key = _valueKeys[selected]; + if (key == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final keyContext = key.currentContext; + if (keyContext == null) return; + + Scrollable.ensureVisible( + keyContext, + curve: Curves.easeInOut, + alignment: 0.5, // Center the item (0.0 is start, 1.0 is end) + duration: const Duration(milliseconds: 300), + ); + }); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final effectiveSpacing = props.spacing ?? spacing.xs; + final effectivePadding = props.padding ?? .symmetric(horizontal: spacing.md); + + return SingleChildScrollView( + padding: effectivePadding, + scrollDirection: .horizontal, + child: Row( + spacing: effectiveSpacing, + children: [ + ?props.leading, + for (final item in props.items) + StreamEmojiChip( + key: _valueKeys[item.value], + emoji: item.emoji, + count: item.count, + isSelected: item.value == props.selected, + onPressed: switch (props.onSelected) { + final onSelected? => () => onSelected( + item.value == props.selected ? null : item.value, + ), + _ => null, + }, + ), + ], + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart b/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart new file mode 100644 index 0000000..f287767 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/list/stream_list_tile.dart @@ -0,0 +1,406 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_list_tile_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/primitives/stream_radius.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 single fixed-height row inspired by Flutter's [ListTile], adapted for the +/// Stream design system. +/// +/// [StreamListTile] displays an optional [leading] widget, a [title], an +/// optional [subtitle] below the title, an optional right-side [description], +/// and an optional [trailing] widget. All slots accept arbitrary widgets — +/// the [leading] is typically a [StreamAvatar] but can be any widget. +/// +/// The tile responds to taps and long-presses via [onTap] and [onLongPress], +/// and supports [enabled] and [selected] states. +/// +/// ## Theming +/// +/// Visual properties are resolved from [StreamListTileTheme], with sensible +/// defaults derived from [StreamColorScheme] and [StreamTextTheme]. +/// +/// ## Material requirement +/// +/// Like Flutter's [ListTile], [StreamListTile] requires a [Material] widget +/// somewhere in its ancestor tree for ink effects to render. A [Scaffold] +/// satisfies this automatically in full-page layouts. When using the tile in +/// isolation (e.g. inside a card or a custom container), wrap it with a +/// [Material]: +/// +/// ```dart +/// Material( +/// type: MaterialType.transparency, +/// child: StreamListTile(...), +/// ) +/// ``` +/// +/// {@tool snippet} +/// +/// A simple list tile with an avatar and a chevron: +/// +/// ```dart +/// StreamListTile( +/// leading: StreamAvatar(name: 'Alice'), +/// title: Text('Alice'), +/// subtitle: Text('Online'), +/// trailing: Icon(Icons.chevron_right), +/// onTap: () => Navigator.push(context, ...), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// A selected tile with a description: +/// +/// ```dart +/// StreamListTile( +/// leading: StreamAvatar(name: 'Bob'), +/// title: Text('Bob'), +/// description: Text('2 min ago'), +/// selected: true, +/// onTap: () {}, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamListTileTheme], for customizing tile appearance globally. +/// * [DefaultStreamListTile], the default visual implementation. +class StreamListTile extends StatelessWidget { + /// Creates a list tile. + StreamListTile({ + super.key, + Widget? leading, + Widget? title, + Widget? subtitle, + Widget? description, + Widget? trailing, + VoidCallback? onTap, + VoidCallback? onLongPress, + bool enabled = true, + bool selected = false, + }) : props = .new( + leading: leading, + title: title, + subtitle: subtitle, + description: description, + trailing: trailing, + onTap: onTap, + onLongPress: onLongPress, + enabled: enabled, + selected: selected, + ); + + /// The props controlling the appearance and behavior of this tile. + final StreamListTileProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.maybeOf(context)?.listTile; + if (builder != null) return builder(context, props); + return DefaultStreamListTile(props: props); + } +} + +/// Properties for configuring a [StreamListTile]. +/// +/// This class holds all the configuration options for a list tile, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamListTile], which uses these properties. +/// * [DefaultStreamListTile], the default implementation. +class StreamListTileProps { + /// Creates properties for a list tile. + const StreamListTileProps({ + this.leading, + this.title, + this.subtitle, + this.description, + this.trailing, + this.onTap, + this.onLongPress, + this.enabled = true, + this.selected = false, + }); + + /// A widget displayed before the title. + /// + /// Typically a [StreamAvatar], [CircleAvatar], or [Icon]. + final Widget? leading; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. Should not wrap — use [Text.maxLines] to + /// enforce a single line. + final Widget? subtitle; + + /// A widget displayed on the right side of the tile, between the title + /// column and [trailing]. + /// + /// Typically a [Text] widget showing secondary metadata such as a timestamp + /// or status. Uses [StreamColorScheme.textTertiary] by default. + final Widget? description; + + /// A widget displayed at the end of the tile. + /// + /// Typically an [Icon] (e.g. a chevron) or a control widget. + final Widget? trailing; + + /// Called when the user taps this tile. + /// + /// Inoperative if [enabled] is false. + final VoidCallback? onTap; + + /// Called when the user long-presses this tile. + /// + /// Inoperative if [enabled] is false. + final VoidCallback? onLongPress; + + /// Whether this tile is interactive. + /// + /// When false, the tile is styled with the disabled color and [onTap] / + /// [onLongPress] are inoperative, mirroring [ListTile.enabled]. + final bool enabled; + + /// Whether this tile is in a selected state. + /// + /// When true, the tile applies selected-state styling via + /// [StreamListTileThemeData] (background color, text colors, and icon + /// colors). This is independent of tap handling. + final bool selected; +} + +/// The default implementation of [StreamListTile]. +/// +/// Renders the tile with theming support from [StreamListTileTheme]. +/// It is used as the default factory implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamListTile], the public API widget. +/// * [StreamListTileProps], which configures this widget. +class DefaultStreamListTile extends StatelessWidget { + /// Creates a default list tile. + const DefaultStreamListTile({super.key, required this.props}); + + /// The props controlling the appearance and behavior of this tile. + final StreamListTileProps props; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + + final spacing = context.streamSpacing; + final theme = context.streamListTileTheme; + final defaults = _StreamListTileThemeDefaults(context); + + // Build the WidgetState set once and share it across all color resolvers. + final states = { + if (!props.enabled) WidgetState.disabled, + if (props.selected) WidgetState.selected, + }; + + final textDirection = Directionality.of(context); + + final effectiveTitleColor = (theme.titleColor ?? defaults.titleColor).resolve(states)!; + final effectiveSubtitleColor = (theme.subtitleColor ?? defaults.subtitleColor).resolve(states)!; + final effectiveDescriptionColor = (theme.descriptionColor ?? defaults.descriptionColor).resolve(states)!; + final effectiveIconColor = (theme.iconColor ?? defaults.iconColor).resolve(states)!; + + final effectiveTitleTextStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleTextStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectiveDescriptionTextStyle = theme.descriptionTextStyle ?? defaults.descriptionTextStyle; + final effectiveBackgroundColor = (theme.backgroundColor ?? defaults.backgroundColor).resolve(states); + final effectiveShape = theme.shape ?? defaults.shape; + final effectiveContentPadding = (theme.contentPadding ?? defaults.contentPadding).resolve(textDirection); + final effectiveMinTileHeight = theme.minTileHeight ?? defaults.minTileHeight; + final effectiveOverlayColor = theme.overlayColor ?? defaults.overlayColor; + + // Mouse cursor: show a non-interactive cursor when the tile is disabled + // OR when no gesture callbacks are wired. + final mouseStates = { + if (!props.enabled || (props.onTap == null && props.onLongPress == null)) WidgetState.disabled, + }; + + final effectiveMouseCursor = WidgetStateMouseCursor.clickable.resolve(mouseStates); + + Widget? leadingWidget; + if (props.leading case final leading?) { + leadingWidget = AnimatedDefaultTextStyle( + style: TextStyle(color: effectiveIconColor), + duration: kThemeChangeDuration, + child: leading, + ); + } + + Widget? titleWidget; + if (props.title case final title?) { + titleWidget = AnimatedDefaultTextStyle( + style: effectiveTitleTextStyle.copyWith(color: effectiveTitleColor), + duration: kThemeChangeDuration, + child: title, + ); + } + + Widget? subtitleWidget; + if (props.subtitle case final subtitle?) { + subtitleWidget = AnimatedDefaultTextStyle( + style: effectiveSubtitleTextStyle.copyWith(color: effectiveSubtitleColor), + duration: kThemeChangeDuration, + child: subtitle, + ); + } + + Widget? descriptionWidget; + if (props.description case final description?) { + descriptionWidget = AnimatedDefaultTextStyle( + style: effectiveDescriptionTextStyle.copyWith(color: effectiveDescriptionColor), + duration: kThemeChangeDuration, + child: description, + ); + } + + Widget? trailingWidget; + if (props.trailing case final trailing?) { + trailingWidget = AnimatedDefaultTextStyle( + style: TextStyle(color: effectiveIconColor), + duration: kThemeChangeDuration, + child: trailing, + ); + } + + return InkWell( + customBorder: effectiveShape, + onTap: props.enabled ? props.onTap : null, + onLongPress: props.enabled ? props.onLongPress : null, + canRequestFocus: props.enabled, + mouseCursor: effectiveMouseCursor, + overlayColor: effectiveOverlayColor, + child: Semantics( + button: props.onTap != null || props.onLongPress != null, + selected: props.selected, + enabled: props.enabled, + child: Ink( + decoration: ShapeDecoration( + shape: effectiveShape, + color: effectiveBackgroundColor, + ), + child: SafeArea( + top: false, + bottom: false, + minimum: effectiveContentPadding, + child: IconTheme.merge( + data: IconThemeData(color: effectiveIconColor), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: effectiveMinTileHeight), + child: Row( + spacing: spacing.xs, + children: [ + ?leadingWidget, + Expanded( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [?titleWidget, ?subtitleWidget], + ), + ), + ?descriptionWidget, + ?trailingWidget, + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// Default theme values for [StreamListTile]. +// +// These defaults are used when no explicit value is provided via +// [StreamListTileThemeData]. The defaults are context-aware and use values +// from [StreamColorScheme], [StreamTextTheme], [StreamSpacing], and +// [StreamRadius]. +class _StreamListTileThemeDefaults extends StreamListTileThemeData { + _StreamListTileThemeDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + late final StreamRadius _radius = _context.streamRadius; + + @override + double get minTileHeight => 40; + + @override + TextStyle get titleTextStyle => _textTheme.bodyDefault; + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault; + + @override + TextStyle get descriptionTextStyle => _textTheme.bodyDefault; + + @override + ShapeBorder get shape => RoundedRectangleBorder(borderRadius: .all(_radius.lg)); + + @override + EdgeInsetsGeometry get contentPadding => .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs); + + @override + WidgetStateProperty get titleColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textPrimary; + }); + + @override + WidgetStateProperty get subtitleColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textTertiary; + }); + + @override + WidgetStateProperty get descriptionColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textTertiary; + }); + + @override + WidgetStateProperty get iconColor => .resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textSecondary; + }); + + @override + WidgetStateProperty get backgroundColor => .resolveWith((states) { + const base = StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty get overlayColor => .resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); +} diff --git a/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart index 7d7f144..a96e17a 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart @@ -65,13 +65,13 @@ extension CategoryIcon on Category { /// /// {@tool snippet} /// -/// With customization: +/// With selected reactions and customization: /// /// ```dart /// final emoji = await StreamReactionPickerSheet.show( /// context: context, /// reactionButtonSize: StreamEmojiButtonSize.lg, -/// showDragHandle: false, +/// selectedReactions: {'+1', 'heart', 'joy'}, /// backgroundColor: Colors.white, /// ); /// ``` @@ -97,6 +97,7 @@ class StreamReactionPickerSheet extends StatefulWidget { required this.scrollController, this.onReactionSelected, this.reactionButtonSize, + this.selectedReactions, }); /// Called when a reaction is tapped. @@ -107,6 +108,12 @@ class StreamReactionPickerSheet extends StatefulWidget { /// Defaults to [StreamEmojiButtonSize.xl] (48px buttons). final StreamEmojiButtonSize? reactionButtonSize; + /// The set of emoji short names that are currently selected. + /// + /// When non-null, emojis whose [Emoji.shortName] (e.g. `"+1"`, `"heart"`, `"joy"`) + /// is contained in this set are rendered in the selected state. + final Set? selectedReactions; + /// Scroll controller for the emoji grid. /// /// This is required and provided by [DraggableScrollableSheet] when using @@ -121,6 +128,7 @@ class StreamReactionPickerSheet extends StatefulWidget { /// Parameters: /// - [context]: The build context for showing the modal. /// - [reactionButtonSize]: Size of each reaction button. Defaults to [StreamEmojiButtonSize.xl]. + /// - [selectedReactions]: A set of emoji short names (e.g. `{'+1', 'heart'}`) that should appear selected. When non-null, matching emojis are highlighted. /// - [backgroundColor]: Background color of the sheet. Defaults to `backgroundElevation2` from the current color scheme. /// /// Returns a [Future] that completes with the selected [Emoji] when a @@ -155,6 +163,7 @@ class StreamReactionPickerSheet extends StatefulWidget { static Future show({ required BuildContext context, StreamEmojiButtonSize? reactionButtonSize, + Set? selectedReactions, Color? backgroundColor, }) { final radius = context.streamRadius; @@ -180,6 +189,7 @@ class StreamReactionPickerSheet extends StatefulWidget { scrollController: scrollController, onReactionSelected: Navigator.of(context).pop, reactionButtonSize: reactionButtonSize, + selectedReactions: selectedReactions, ), ), ); @@ -394,6 +404,7 @@ class _StreamReactionPickerSheetState extends State w return StreamEmojiButton( size: effectiveButtonSize, emoji: Text(emoji.emoji), + isSelected: widget.selectedReactions?.contains(emoji.shortName), onPressed: () => widget.onReactionSelected?.call(emoji), ); }, diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index 0bde159..25aa478 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -159,7 +159,10 @@ class StreamComponentBuilders with _$StreamComponentBuilders { this.contextMenuAction, this.emoji, this.emojiButton, + this.emojiChip, + this.emojiChipBar, this.fileTypeIcon, + this.listTile, this.onlineIndicator, this.progressBar, }); @@ -209,11 +212,26 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamEmojiButton] uses [DefaultStreamEmojiButton]. final StreamComponentBuilder? emojiButton; + /// Custom builder for emoji chip widgets. + /// + /// When null, [StreamEmojiChip] uses [DefaultStreamEmojiChip]. + final StreamComponentBuilder? emojiChip; + + /// Custom builder for emoji chip bar widgets. + /// + /// When null, [StreamEmojiChipBar] uses [DefaultStreamEmojiChipBar]. + final StreamComponentBuilder? emojiChipBar; + /// Custom builder for file type icon widgets. /// /// When null, [StreamFileTypeIcon] uses [DefaultStreamFileTypeIcon]. final StreamComponentBuilder? fileTypeIcon; + /// Custom builder for list tile widgets. + /// + /// When null, [StreamListTile] uses [DefaultStreamListTile]. + final StreamComponentBuilder? listTile; + /// Custom builder for online indicator widgets. /// /// When null, [StreamOnlineIndicator] uses [DefaultStreamOnlineIndicator]. diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart index d9ea8d1..15ce6a9 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart @@ -39,7 +39,10 @@ mixin _$StreamComponentBuilders { contextMenuAction: t < 0.5 ? a.contextMenuAction : b.contextMenuAction, emoji: t < 0.5 ? a.emoji : b.emoji, emojiButton: t < 0.5 ? a.emojiButton : b.emojiButton, + emojiChip: t < 0.5 ? a.emojiChip : b.emojiChip, + emojiChipBar: t < 0.5 ? a.emojiChipBar : b.emojiChipBar, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, + listTile: t < 0.5 ? a.listTile : b.listTile, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, progressBar: t < 0.5 ? a.progressBar : b.progressBar, ); @@ -56,7 +59,11 @@ mixin _$StreamComponentBuilders { contextMenuAction, Widget Function(BuildContext, StreamEmojiProps)? emoji, Widget Function(BuildContext, StreamEmojiButtonProps)? emojiButton, + Widget Function(BuildContext, StreamEmojiChipProps)? emojiChip, + Widget Function(BuildContext, StreamEmojiChipBarProps)? + emojiChipBar, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, + Widget Function(BuildContext, StreamListTileProps)? listTile, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, Widget Function(BuildContext, StreamProgressBarProps)? progressBar, }) { @@ -72,7 +79,10 @@ mixin _$StreamComponentBuilders { contextMenuAction: contextMenuAction ?? _this.contextMenuAction, emoji: emoji ?? _this.emoji, emojiButton: emojiButton ?? _this.emojiButton, + emojiChip: emojiChip ?? _this.emojiChip, + emojiChipBar: emojiChipBar ?? _this.emojiChipBar, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, + listTile: listTile ?? _this.listTile, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, progressBar: progressBar ?? _this.progressBar, ); @@ -99,7 +109,10 @@ mixin _$StreamComponentBuilders { contextMenuAction: other.contextMenuAction, emoji: other.emoji, emojiButton: other.emojiButton, + emojiChip: other.emojiChip, + emojiChipBar: other.emojiChipBar, fileTypeIcon: other.fileTypeIcon, + listTile: other.listTile, onlineIndicator: other.onlineIndicator, progressBar: other.progressBar, ); @@ -127,7 +140,10 @@ mixin _$StreamComponentBuilders { _other.contextMenuAction == _this.contextMenuAction && _other.emoji == _this.emoji && _other.emojiButton == _this.emojiButton && + _other.emojiChip == _this.emojiChip && + _other.emojiChipBar == _this.emojiChipBar && _other.fileTypeIcon == _this.fileTypeIcon && + _other.listTile == _this.listTile && _other.onlineIndicator == _this.onlineIndicator && _other.progressBar == _this.progressBar; } @@ -147,7 +163,10 @@ mixin _$StreamComponentBuilders { _this.contextMenuAction, _this.emoji, _this.emojiButton, + _this.emojiChip, + _this.emojiChipBar, _this.fileTypeIcon, + _this.listTile, _this.onlineIndicator, _this.progressBar, ); diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 78452e7..eca5390 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -7,7 +7,9 @@ export 'theme/components/stream_checkbox_theme.dart'; export 'theme/components/stream_context_menu_action_theme.dart'; export 'theme/components/stream_context_menu_theme.dart'; export 'theme/components/stream_emoji_button_theme.dart'; +export 'theme/components/stream_emoji_chip_theme.dart'; export 'theme/components/stream_input_theme.dart'; +export 'theme/components/stream_list_tile_theme.dart'; export 'theme/components/stream_message_theme.dart'; export 'theme/components/stream_online_indicator_theme.dart'; export 'theme/components/stream_progress_bar_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.dart new file mode 100644 index 0000000..6d13ce4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_emoji_chip_theme.g.theme.dart'; + +/// Applies an emoji chip theme to descendant emoji chip widgets. +/// +/// Wrap a subtree with [StreamEmojiChipTheme] to override emoji chip styling. +/// Access the merged theme using [BuildContext.streamEmojiChipTheme]. +/// +/// {@tool snippet} +/// +/// Override emoji chip styling for a specific section: +/// +/// ```dart +/// StreamEmojiChipTheme( +/// data: StreamEmojiChipThemeData( +/// style: StreamEmojiChipThemeStyle( +/// foregroundColor: WidgetStateProperty.all(Colors.blue), +/// ), +/// ), +/// child: StreamEmojiChip( +/// emoji: Text('👍'), +/// count: 3, +/// onPressed: () {}, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiChipThemeData], which describes the emoji chip theme. +class StreamEmojiChipTheme extends InheritedTheme { + /// Creates an emoji chip theme that controls descendant emoji chips. + const StreamEmojiChipTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The emoji chip theme data for descendant widgets. + final StreamEmojiChipThemeData data; + + /// Returns the [StreamEmojiChipThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. + static StreamEmojiChipThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).emojiChipTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamEmojiChipTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamEmojiChipTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing emoji chip widgets. +/// +/// {@tool snippet} +/// +/// Customize emoji chip appearance globally: +/// +/// ```dart +/// StreamTheme( +/// emojiChipTheme: StreamEmojiChipThemeData( +/// style: StreamEmojiChipThemeStyle( +/// foregroundColor: WidgetStateProperty.resolveWith((states) { +/// if (states.contains(WidgetState.disabled)) return Colors.grey; +/// return Colors.black; +/// }), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiChipTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamEmojiChipThemeData with _$StreamEmojiChipThemeData { + /// Creates an emoji chip theme with optional style overrides. + const StreamEmojiChipThemeData({this.style}); + + /// The visual styling for emoji chips. + final StreamEmojiChipThemeStyle? style; + + /// Linearly interpolate between two [StreamEmojiChipThemeData] objects. + static StreamEmojiChipThemeData? lerp( + StreamEmojiChipThemeData? a, + StreamEmojiChipThemeData? b, + double t, + ) => _$StreamEmojiChipThemeData.lerp(a, b, t); +} + +/// Visual styling properties for emoji chips. +/// +/// Defines the appearance of emoji chips including background, foreground, +/// overlay colors, size, padding, and border styling. All color properties +/// support state-based styling for interactive feedback. +/// +/// See also: +/// +/// * [StreamEmojiChipThemeData], which wraps this style for theming. +/// * [StreamEmojiChip], which uses this styling. +@themeGen +@immutable +class StreamEmojiChipThemeStyle with _$StreamEmojiChipThemeStyle { + /// Creates emoji chip style properties. + const StreamEmojiChipThemeStyle({ + this.backgroundColor, + this.foregroundColor, + this.overlayColor, + this.textStyle, + this.emojiSize, + this.minimumSize, + this.maximumSize, + this.padding, + this.shape, + WidgetStateProperty? side, + }) + // TODO: Fix this or try to find something better + : side = side as WidgetStateBorderSide?; + + /// The background color for emoji chips. + /// + /// Supports state-based colors for different interaction states + /// (default, hover, pressed, disabled, selected). + final WidgetStateProperty? backgroundColor; + + /// The foreground color for emoji/icon and count text content. + /// + /// Supports state-based colors for different interaction states. + final WidgetStateProperty? foregroundColor; + + /// The overlay color for interaction feedback (hover, press). + /// + /// Supports state-based colors for hover and press states. + final WidgetStateProperty? overlayColor; + + /// The text style for the reaction count label. + /// + /// The color of [textStyle] is typically not used directly — use + /// [foregroundColor] to control text and icon color instead. + final WidgetStateProperty? textStyle; + + /// The display size of the emoji/icon in logical pixels. + /// + /// Falls back to [StreamEmojiSize.sm] (16px). + final double? emojiSize; + + /// The minimum size of the chip. + /// + /// Falls back to `Size(64, 32)`. + final Size? minimumSize; + + /// The maximum size of the chip. + /// + /// Falls back to `Size.fromHeight(32)` — unconstrained width, fixed 32px height. + final Size? maximumSize; + + /// The internal padding of the chip. + /// + /// Falls back to horizontal `StreamSpacing.sm` and vertical + /// `StreamSpacing.xxs + StreamSpacing.xxxs`. + final EdgeInsetsGeometry? padding; + + /// The shape of the chip's container. + /// + /// Falls back to a [RoundedRectangleBorder] with `StreamRadius.max`. + final OutlinedBorder? shape; + + /// The border for the chip. + /// + /// Supports state-based borders for different interaction states. + final WidgetStateBorderSide? side; + + /// Linearly interpolate between two [StreamEmojiChipThemeStyle] objects. + static StreamEmojiChipThemeStyle? lerp( + StreamEmojiChipThemeStyle? a, + StreamEmojiChipThemeStyle? b, + double t, + ) => _$StreamEmojiChipThemeStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart new file mode 100644 index 0000000..806d716 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart @@ -0,0 +1,231 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_emoji_chip_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamEmojiChipThemeData { + bool get canMerge => true; + + static StreamEmojiChipThemeData? lerp( + StreamEmojiChipThemeData? a, + StreamEmojiChipThemeData? 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 StreamEmojiChipThemeData( + style: StreamEmojiChipThemeStyle.lerp(a.style, b.style, t), + ); + } + + StreamEmojiChipThemeData copyWith({StreamEmojiChipThemeStyle? style}) { + final _this = (this as StreamEmojiChipThemeData); + + return StreamEmojiChipThemeData(style: style ?? _this.style); + } + + StreamEmojiChipThemeData merge(StreamEmojiChipThemeData? other) { + final _this = (this as StreamEmojiChipThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamEmojiChipThemeData); + final _other = (other as StreamEmojiChipThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamEmojiChipThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamEmojiChipThemeStyle { + bool get canMerge => true; + + static StreamEmojiChipThemeStyle? lerp( + StreamEmojiChipThemeStyle? a, + StreamEmojiChipThemeStyle? 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 StreamEmojiChipThemeStyle( + backgroundColor: WidgetStateProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + foregroundColor: WidgetStateProperty.lerp( + a.foregroundColor, + b.foregroundColor, + t, + Color.lerp, + ), + overlayColor: WidgetStateProperty.lerp( + a.overlayColor, + b.overlayColor, + t, + Color.lerp, + ), + textStyle: WidgetStateProperty.lerp( + a.textStyle, + b.textStyle, + t, + TextStyle.lerp, + ), + emojiSize: lerpDouble$(a.emojiSize, b.emojiSize, t), + minimumSize: Size.lerp(a.minimumSize, b.minimumSize, t), + maximumSize: Size.lerp(a.maximumSize, b.maximumSize, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + shape: OutlinedBorder.lerp(a.shape, b.shape, t), + side: WidgetStateBorderSide.lerp(a.side, b.side, t), + ); + } + + StreamEmojiChipThemeStyle copyWith({ + WidgetStateProperty? backgroundColor, + WidgetStateProperty? foregroundColor, + WidgetStateProperty? overlayColor, + WidgetStateProperty? textStyle, + double? emojiSize, + Size? minimumSize, + Size? maximumSize, + EdgeInsetsGeometry? padding, + OutlinedBorder? shape, + WidgetStateBorderSide? side, + }) { + final _this = (this as StreamEmojiChipThemeStyle); + + return StreamEmojiChipThemeStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + foregroundColor: foregroundColor ?? _this.foregroundColor, + overlayColor: overlayColor ?? _this.overlayColor, + textStyle: textStyle ?? _this.textStyle, + emojiSize: emojiSize ?? _this.emojiSize, + minimumSize: minimumSize ?? _this.minimumSize, + maximumSize: maximumSize ?? _this.maximumSize, + padding: padding ?? _this.padding, + shape: shape ?? _this.shape, + side: side ?? _this.side, + ); + } + + StreamEmojiChipThemeStyle merge(StreamEmojiChipThemeStyle? other) { + final _this = (this as StreamEmojiChipThemeStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + foregroundColor: other.foregroundColor, + overlayColor: other.overlayColor, + textStyle: other.textStyle, + emojiSize: other.emojiSize, + minimumSize: other.minimumSize, + maximumSize: other.maximumSize, + padding: other.padding, + shape: other.shape, + side: other.side, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamEmojiChipThemeStyle); + final _other = (other as StreamEmojiChipThemeStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.foregroundColor == _this.foregroundColor && + _other.overlayColor == _this.overlayColor && + _other.textStyle == _this.textStyle && + _other.emojiSize == _this.emojiSize && + _other.minimumSize == _this.minimumSize && + _other.maximumSize == _this.maximumSize && + _other.padding == _this.padding && + _other.shape == _this.shape && + _other.side == _this.side; + } + + @override + int get hashCode { + final _this = (this as StreamEmojiChipThemeStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.foregroundColor, + _this.overlayColor, + _this.textStyle, + _this.emojiSize, + _this.minimumSize, + _this.maximumSize, + _this.padding, + _this.shape, + _this.side, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_list_tile_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_list_tile_theme.dart new file mode 100644 index 0000000..99c5e3f --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_list_tile_theme.dart @@ -0,0 +1,173 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_list_tile_theme.g.theme.dart'; + +/// Applies a list tile theme to descendant [StreamListTile] widgets. +/// +/// Wrap a subtree with [StreamListTileTheme] to override list tile styling. +/// Access the merged theme using [BuildContext.streamListTileTheme]. +/// +/// {@tool snippet} +/// +/// Override list tile styling for a specific section: +/// +/// ```dart +/// StreamListTileTheme( +/// data: StreamListTileThemeData( +/// shape: RoundedRectangleBorder( +/// borderRadius: BorderRadius.circular(8), +/// ), +/// ), +/// child: StreamListTile( +/// title: Text('Hello'), +/// onTap: () {}, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamListTileThemeData], which describes the list tile theme. +/// * [StreamListTile], the widget affected by this theme. +class StreamListTileTheme extends InheritedTheme { + /// Creates a list tile theme that controls descendant list tiles. + const StreamListTileTheme({super.key, required this.data, required super.child}); + + /// The list tile theme data for descendant widgets. + final StreamListTileThemeData data; + + /// Returns the [StreamListTileThemeData] merged from local and global themes. + /// + /// Local values from the nearest [StreamListTileTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamListTileThemeData.contentPadding] while inheriting colors from + /// the global theme. + static StreamListTileThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).listTileTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamListTileTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamListTileTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamListTile] widgets. +/// +/// Descendant widgets obtain their values from [StreamListTileTheme.of]. +/// All properties are null by default, with fallback values applied by +/// [DefaultStreamListTile]. +/// +/// {@tool snippet} +/// +/// Customize list tile appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// data: StreamThemeData( +/// listTileTheme: StreamListTileThemeData( +/// contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), +/// shape: RoundedRectangleBorder( +/// borderRadius: BorderRadius.circular(8), +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamListTileTheme], for overriding the theme in a widget subtree. +/// * [StreamListTile], the widget that uses this theme data. +@themeGen +@immutable +class StreamListTileThemeData with _$StreamListTileThemeData { + /// Creates a list tile theme data with optional property overrides. + const StreamListTileThemeData({ + this.titleTextStyle, + this.subtitleTextStyle, + this.descriptionTextStyle, + this.titleColor, + this.subtitleColor, + this.descriptionColor, + this.iconColor, + this.backgroundColor, + this.shape, + this.contentPadding, + this.minTileHeight, + this.overlayColor, + }); + + /// Defines the default text style for [StreamListTile.title]. + /// + /// This only controls typography. Color comes from [titleColor]. + final TextStyle? titleTextStyle; + + /// Defines the default text style for [StreamListTile.subtitle]. + /// + /// This only controls typography. Color comes from [subtitleColor]. + final TextStyle? subtitleTextStyle; + + /// Defines the default text style for [StreamListTile.description]. + /// + /// This only controls typography. Color comes from [descriptionColor]. + final TextStyle? descriptionTextStyle; + + /// Defines the default color for [StreamListTile.title]. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? titleColor; + + /// Defines the default color for [StreamListTile.subtitle]. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? subtitleColor; + + /// Defines the default color for [StreamListTile.description]. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? descriptionColor; + + /// Defines the default color for [StreamListTile.leading] and + /// [StreamListTile.trailing]. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? iconColor; + + /// Defines the default background color of the tile. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? backgroundColor; + + /// Defines the default shape for the tile. + final ShapeBorder? shape; + + /// Defines the default internal padding of the tile's content. + final EdgeInsetsGeometry? contentPadding; + + /// Defines the default minimum height of the tile. + final double? minTileHeight; + + /// Defines the default overlay color for tile interactions. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? overlayColor; + + /// Linearly interpolate between two [StreamListTileThemeData] objects. + static StreamListTileThemeData? lerp( + StreamListTileThemeData? a, + StreamListTileThemeData? b, + double t, + ) => _$StreamListTileThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_list_tile_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_list_tile_theme.g.theme.dart new file mode 100644 index 0000000..01dc224 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_list_tile_theme.g.theme.dart @@ -0,0 +1,202 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_list_tile_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamListTileThemeData { + bool get canMerge => true; + + static StreamListTileThemeData? lerp( + StreamListTileThemeData? a, + StreamListTileThemeData? 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 StreamListTileThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + descriptionTextStyle: TextStyle.lerp( + a.descriptionTextStyle, + b.descriptionTextStyle, + t, + ), + titleColor: WidgetStateProperty.lerp( + a.titleColor, + b.titleColor, + t, + Color.lerp, + ), + subtitleColor: WidgetStateProperty.lerp( + a.subtitleColor, + b.subtitleColor, + t, + Color.lerp, + ), + descriptionColor: WidgetStateProperty.lerp( + a.descriptionColor, + b.descriptionColor, + t, + Color.lerp, + ), + iconColor: WidgetStateProperty.lerp( + a.iconColor, + b.iconColor, + t, + Color.lerp, + ), + backgroundColor: WidgetStateProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + shape: ShapeBorder.lerp(a.shape, b.shape, t), + contentPadding: EdgeInsetsGeometry.lerp( + a.contentPadding, + b.contentPadding, + t, + ), + minTileHeight: lerpDouble$(a.minTileHeight, b.minTileHeight, t), + overlayColor: WidgetStateProperty.lerp( + a.overlayColor, + b.overlayColor, + t, + Color.lerp, + ), + ); + } + + StreamListTileThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + TextStyle? descriptionTextStyle, + WidgetStateProperty? titleColor, + WidgetStateProperty? subtitleColor, + WidgetStateProperty? descriptionColor, + WidgetStateProperty? iconColor, + WidgetStateProperty? backgroundColor, + ShapeBorder? shape, + EdgeInsetsGeometry? contentPadding, + double? minTileHeight, + WidgetStateProperty? overlayColor, + }) { + final _this = (this as StreamListTileThemeData); + + return StreamListTileThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + descriptionTextStyle: descriptionTextStyle ?? _this.descriptionTextStyle, + titleColor: titleColor ?? _this.titleColor, + subtitleColor: subtitleColor ?? _this.subtitleColor, + descriptionColor: descriptionColor ?? _this.descriptionColor, + iconColor: iconColor ?? _this.iconColor, + backgroundColor: backgroundColor ?? _this.backgroundColor, + shape: shape ?? _this.shape, + contentPadding: contentPadding ?? _this.contentPadding, + minTileHeight: minTileHeight ?? _this.minTileHeight, + overlayColor: overlayColor ?? _this.overlayColor, + ); + } + + StreamListTileThemeData merge(StreamListTileThemeData? other) { + final _this = (this as StreamListTileThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + descriptionTextStyle: + _this.descriptionTextStyle?.merge(other.descriptionTextStyle) ?? + other.descriptionTextStyle, + titleColor: other.titleColor, + subtitleColor: other.subtitleColor, + descriptionColor: other.descriptionColor, + iconColor: other.iconColor, + backgroundColor: other.backgroundColor, + shape: other.shape, + contentPadding: other.contentPadding, + minTileHeight: other.minTileHeight, + overlayColor: other.overlayColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamListTileThemeData); + final _other = (other as StreamListTileThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.descriptionTextStyle == _this.descriptionTextStyle && + _other.titleColor == _this.titleColor && + _other.subtitleColor == _this.subtitleColor && + _other.descriptionColor == _this.descriptionColor && + _other.iconColor == _this.iconColor && + _other.backgroundColor == _this.backgroundColor && + _other.shape == _this.shape && + _other.contentPadding == _this.contentPadding && + _other.minTileHeight == _this.minTileHeight && + _other.overlayColor == _this.overlayColor; + } + + @override + int get hashCode { + final _this = (this as StreamListTileThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.descriptionTextStyle, + _this.titleColor, + _this.subtitleColor, + _this.descriptionColor, + _this.iconColor, + _this.backgroundColor, + _this.shape, + _this.contentPadding, + _this.minTileHeight, + _this.overlayColor, + ); + } +} 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 3870084..40805d6 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -11,7 +11,9 @@ import 'components/stream_checkbox_theme.dart'; import 'components/stream_context_menu_action_theme.dart'; import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; +import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; +import 'components/stream_list_tile_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -96,6 +98,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamContextMenuThemeData? contextMenuTheme, StreamContextMenuActionThemeData? contextMenuActionTheme, StreamEmojiButtonThemeData? emojiButtonTheme, + StreamEmojiChipThemeData? emojiChipTheme, + StreamListTileThemeData? listTileTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -123,6 +127,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { contextMenuTheme ??= const StreamContextMenuThemeData(); contextMenuActionTheme ??= const StreamContextMenuActionThemeData(); emojiButtonTheme ??= const StreamEmojiButtonThemeData(); + emojiChipTheme ??= const StreamEmojiChipThemeData(); + listTileTheme ??= const StreamListTileThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -144,6 +150,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { contextMenuTheme: contextMenuTheme, contextMenuActionTheme: contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme, + emojiChipTheme: emojiChipTheme, + listTileTheme: listTileTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, @@ -179,6 +187,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.contextMenuTheme, required this.contextMenuActionTheme, required this.emojiButtonTheme, + required this.emojiChipTheme, + required this.listTileTheme, required this.messageTheme, required this.inputTheme, required this.onlineIndicatorTheme, @@ -264,6 +274,12 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The emoji button theme for this theme. final StreamEmojiButtonThemeData emojiButtonTheme; + /// The emoji chip theme for this theme. + final StreamEmojiChipThemeData emojiChipTheme; + + /// The list tile theme for this theme. + final StreamListTileThemeData listTileTheme; + /// The message theme for this theme. final StreamMessageThemeData messageTheme; @@ -312,6 +328,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { contextMenuTheme: contextMenuTheme, contextMenuActionTheme: contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme, + emojiChipTheme: emojiChipTheme, + listTileTheme: listTileTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, 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 5af58e2..4e71d2f 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 @@ -27,6 +27,8 @@ mixin _$StreamTheme on ThemeExtension { StreamContextMenuThemeData? contextMenuTheme, StreamContextMenuActionThemeData? contextMenuActionTheme, StreamEmojiButtonThemeData? emojiButtonTheme, + StreamEmojiChipThemeData? emojiChipTheme, + StreamListTileThemeData? listTileTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -51,6 +53,8 @@ mixin _$StreamTheme on ThemeExtension { contextMenuActionTheme: contextMenuActionTheme ?? _this.contextMenuActionTheme, emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, + emojiChipTheme: emojiChipTheme ?? _this.emojiChipTheme, + listTileTheme: listTileTheme ?? _this.listTileTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, @@ -114,6 +118,16 @@ mixin _$StreamTheme on ThemeExtension { other.emojiButtonTheme, t, )!, + emojiChipTheme: StreamEmojiChipThemeData.lerp( + _this.emojiChipTheme, + other.emojiChipTheme, + t, + )!, + listTileTheme: StreamListTileThemeData.lerp( + _this.listTileTheme, + other.listTileTheme, + t, + )!, messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, inputTheme: t < 0.5 ? _this.inputTheme : other.inputTheme, onlineIndicatorTheme: StreamOnlineIndicatorThemeData.lerp( @@ -157,6 +171,8 @@ mixin _$StreamTheme on ThemeExtension { _other.contextMenuTheme == _this.contextMenuTheme && _other.contextMenuActionTheme == _this.contextMenuActionTheme && _other.emojiButtonTheme == _this.emojiButtonTheme && + _other.emojiChipTheme == _this.emojiChipTheme && + _other.listTileTheme == _this.listTileTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && @@ -167,7 +183,7 @@ mixin _$StreamTheme on ThemeExtension { int get hashCode { final _this = (this as StreamTheme); - return Object.hash( + return Object.hashAll([ runtimeType, _this.brightness, _this.icons, @@ -184,10 +200,12 @@ mixin _$StreamTheme on ThemeExtension { _this.contextMenuTheme, _this.contextMenuActionTheme, _this.emojiButtonTheme, + _this.emojiChipTheme, + _this.listTileTheme, _this.messageTheme, _this.inputTheme, _this.onlineIndicatorTheme, _this.progressBarTheme, - ); + ]); } } 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 d6c414e..40f8d42 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 @@ -7,7 +7,9 @@ import 'components/stream_checkbox_theme.dart'; import 'components/stream_context_menu_action_theme.dart'; import 'components/stream_context_menu_theme.dart'; import 'components/stream_emoji_button_theme.dart'; +import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; +import 'components/stream_list_tile_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -86,6 +88,12 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamEmojiButtonThemeData] from the nearest ancestor. StreamEmojiButtonThemeData get streamEmojiButtonTheme => StreamEmojiButtonTheme.of(this); + /// Returns the [StreamEmojiChipThemeData] from the nearest ancestor. + StreamEmojiChipThemeData get streamEmojiChipTheme => StreamEmojiChipTheme.of(this); + + /// Returns the [StreamListTileThemeData] from the nearest ancestor. + StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMessageThemeData] from the nearest ancestor. StreamMessageThemeData get streamMessageTheme => StreamMessageTheme.of(this); diff --git a/pubspec.yaml b/pubspec.yaml index 31a2dd4..6e9eabd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,4 +9,4 @@ dev_dependencies: melos: ^6.2.0 path: ^1.9.0 recase: ^4.1.0 - yaml: ^3.1.3 + yaml: ^3.1.3 \ No newline at end of file