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 d6494aa..d3a8f24 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 @@ -46,6 +46,20 @@ import 'package:design_system_gallery/components/controls/stream_emoji_chip_bar. as _design_system_gallery_components_controls_stream_emoji_chip_bar; import 'package:design_system_gallery/components/emoji/stream_emoji_picker_sheet.dart' as _design_system_gallery_components_emoji_stream_emoji_picker_sheet; +import 'package:design_system_gallery/components/message/stream_message_annotation.dart' + as _design_system_gallery_components_message_stream_message_annotation; +import 'package:design_system_gallery/components/message/stream_message_bubble.dart' + as _design_system_gallery_components_message_stream_message_bubble; +import 'package:design_system_gallery/components/message/stream_message_content.dart' + as _design_system_gallery_components_message_stream_message_content; +import 'package:design_system_gallery/components/message/stream_message_metadata.dart' + as _design_system_gallery_components_message_stream_message_metadata; +import 'package:design_system_gallery/components/message/stream_message_replies.dart' + as _design_system_gallery_components_message_stream_message_replies; +import 'package:design_system_gallery/components/message/stream_message_text.dart' + as _design_system_gallery_components_message_stream_message_text; +import 'package:design_system_gallery/components/message/stream_message_widget.dart' + as _design_system_gallery_components_message_stream_message_widget; 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' @@ -55,7 +69,7 @@ import 'package:design_system_gallery/components/message_composer/message_compos import 'package:design_system_gallery/components/message_composer/message_composer_attachment_reply.dart' as _design_system_gallery_components_message_composer_message_composer_attachment_reply; import 'package:design_system_gallery/components/reaction/stream_reactions.dart' - as _design_system_gallery_components_reaction_stream_reaction; + as _design_system_gallery_components_reaction_stream_reactions; 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' @@ -515,6 +529,130 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Message', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamMessageAnnotation', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_annotation + .buildStreamMessageAnnotationPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_annotation + .buildStreamMessageAnnotationShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageBubble', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_bubble + .buildStreamMessageBubblePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_bubble + .buildStreamMessageBubbleShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageContent', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_content + .buildStreamMessageContentPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_content + .buildStreamMessageContentShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageMetadata', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_metadata + .buildStreamMessageMetadataPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_metadata + .buildStreamMessageMetadataShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageReplies', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_replies + .buildStreamMessageRepliesPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_replies + .buildStreamMessageRepliesShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageText', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_text + .buildStreamMessageTextPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_text + .buildStreamMessageTextShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageWidget', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_widget + .buildStreamMessageWidgetPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_widget + .buildStreamMessageWidgetShowcase, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Message Composer', children: [ @@ -579,13 +717,13 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_reaction_stream_reaction + _design_system_gallery_components_reaction_stream_reactions .buildStreamReactionsPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_reaction_stream_reaction + _design_system_gallery_components_reaction_stream_reactions .buildStreamReactionsShowcase, ), ], diff --git a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart new file mode 100644 index 0000000..09c18a6 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart @@ -0,0 +1,534 @@ +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: StreamMessageAnnotation, + path: '[Components]/Message', +) +Widget buildStreamMessageAnnotationPlayground(BuildContext context) { + final icons = context.streamIcons; + + final label = context.knobs.string( + label: 'Label', + initialValue: 'Saved for later', + description: 'The annotation label text.', + ); + + final showLeading = context.knobs.boolean( + label: 'Show Leading', + initialValue: true, + description: 'Whether to show a leading icon.', + ); + + final leadingIcon = context.knobs.object.dropdown<_IconOption>( + label: 'Leading Icon', + options: _IconOption.values, + initialOption: _IconOption.bookmark, + labelBuilder: (v) => v.label, + description: 'The leading icon to display.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 4, + max: 16, + divisions: 16, + description: 'Gap between icon and label. Overrides theme when set.', + ); + + final verticalPadding = context.knobs.double.slider( + label: 'Vertical Padding', + initialValue: 4, + max: 16, + divisions: 16, + description: 'Vertical padding around the row content.', + ); + + final horizontalPadding = context.knobs.double.slider( + label: 'Horizontal Padding', + max: 16, + divisions: 16, + description: 'Horizontal padding around the row content.', + ); + + return Center( + child: StreamMessageAnnotation( + leading: showLeading ? Icon(leadingIcon.resolve(icons)) : null, + label: Text(label), + style: StreamMessageAnnotationStyle.from( + spacing: spacing, + padding: EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ), + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageAnnotation, + path: '[Components]/Message', +) +Widget buildStreamMessageAnnotationShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _AnnotationTypesSection(), + _ThemeOverrideSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _AnnotationTypesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return _Section( + label: 'ANNOTATION TYPES', + description: 'All annotation variants from the design system.', + children: [ + _ExampleCard( + label: 'Saved', + subtitle: 'Accent color for icon and text.', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( + textColor: colorScheme.accentPrimary, + iconColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + ), + ), + ), + _ExampleCard( + label: 'Pinned', + child: StreamMessageAnnotation( + leading: Icon(icons.pin), + label: const Text('Pinned by Alice'), + ), + ), + _ExampleCard( + label: 'Reminder', + subtitle: 'Mixed emphasis: bold label + regular timestamp.', + child: StreamMessageAnnotation( + leading: Icon(icons.bellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Translated', + subtitle: 'Regular label + bold action text.', + child: StreamMessageAnnotation( + leading: const Icon(Icons.translate), + label: Text.rich( + TextSpan( + style: textTheme.metadataDefault, + children: [ + const TextSpan(text: 'Translated '), + TextSpan( + text: '· Show original', + style: textTheme.metadataEmphasis, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Also sent in channel', + subtitle: 'Primary text with inline link.', + child: StreamMessageAnnotation( + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Also sent in channel · '), + TextSpan( + text: 'View', + style: TextStyle(color: colorScheme.textLink), + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Replied to a thread', + subtitle: 'Primary text with inline link.', + child: StreamMessageAnnotation( + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Replied to a thread · '), + TextSpan( + text: 'View', + style: TextStyle(color: colorScheme.textLink), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance overrides via StreamMessageItemTheme.', + children: [ + _ExampleCard( + label: 'Custom colors', + subtitle: 'Purple icon and text color.', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( + textColor: Colors.purple, + iconColor: Colors.purple, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + ), + ), + ), + _ExampleCard( + label: 'Custom icon size', + subtitle: 'Larger icon (20px).', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( + iconSize: 20, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.pin), + label: const Text('Pinned by Alice'), + ), + ), + ), + _ExampleCard( + label: 'Custom spacing', + subtitle: 'Wider gap (12px) between icon and label.', + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + style: StreamMessageAnnotationStyle.from(spacing: 12), + ), + ), + _ExampleCard( + label: 'Label only', + subtitle: 'No leading icon.', + child: StreamMessageAnnotation( + label: const Text('Saved for later'), + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Annotations displayed above message bubbles.', + children: [ + _ExampleCard( + label: 'Saved message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( + textColor: colorScheme.accentPrimary, + iconColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + ), + ), + StreamMessageBubble( + child: StreamMessageText('Check out this new design system!'), + ), + ], + ), + ), + _ExampleCard( + label: 'Pinned message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotation( + leading: Icon(icons.pin), + label: const Text('Pinned by Alice'), + ), + StreamMessageBubble( + child: StreamMessageText('Meeting at 3 PM today.'), + ), + ], + ), + ), + _ExampleCard( + label: 'Reminder message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotation( + leading: Icon(icons.bellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + StreamMessageBubble( + child: StreamMessageText('Remember to review the PR.'), + ), + ], + ), + ), + _ExampleCard( + label: 'Also sent in channel', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotation( + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Also sent in channel · '), + TextSpan( + text: 'View', + style: TextStyle(color: colorScheme.textLink), + ), + ], + ), + ), + ), + StreamMessageBubble( + child: StreamMessageText('This was also sent to the main channel.'), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets & Data +// ============================================================================= + +enum _IconOption { + bookmark('Bookmark'), + pin('Pin'), + bellNotification('Bell'), + arrowUp('Arrow Up'), + translate('Translate') + ; + + const _IconOption(this.label); + + final String label; + + IconData resolve(StreamIcons icons) => switch (this) { + bookmark => icons.bookmark, + pin => icons.pin, + bellNotification => icons.bellNotification, + arrowUp => icons.arrowUp, + translate => Icons.translate, + }; +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart new file mode 100644 index 0000000..8cceca4 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart @@ -0,0 +1,421 @@ +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: StreamMessageBubble, + path: '[Components]/Message', +) +Widget buildStreamMessageBubblePlayground(BuildContext context) { + final text = context.knobs.string( + label: 'Text', + initialValue: 'Hello, world!', + description: 'The text content inside the bubble.', + ); + + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamMessageAlignment.values, + labelBuilder: (v) => v.name, + description: 'Start (incoming) or end (outgoing).', + ); + + final stackPosition = context.knobs.object.dropdown( + label: 'Stack Position', + options: StreamMessageStackPosition.values, + labelBuilder: (v) => v.name, + description: 'Position within a consecutive message group.', + ); + + final placement = StreamMessagePlacementData( + alignment: alignment, + stackPosition: stackPosition, + ); + + return Center( + child: StreamMessagePlacement( + placement: placement, + child: StreamMessageBubble( + child: StreamMessageText(text), + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageBubble, + path: '[Components]/Message', +) +Widget buildStreamMessageBubbleShowcase(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _AlignmentSection(), + _StackPositionsSection(), + _ConversationSection(), + _StyleOverrideSection(), + ], + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _AlignmentSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const _Section( + label: 'ALIGNMENT', + description: + 'Bubbles resolve background, border, and shape from the ' + 'placement alignment (start = incoming, end = outgoing).', + children: [ + _ExampleCard( + label: 'Start (incoming)', + child: _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.start, + text: 'Has anyone tried the new Flutter update?', + ), + ), + _ExampleCard( + label: 'End (outgoing)', + child: _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.end, + text: 'Sure, I can help with that!', + ), + ), + ], + ); + } +} + +class _StackPositionsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const _Section( + label: 'STACK POSITIONS', + description: + 'Corner radii change based on position within a ' + 'consecutive message stack.', + children: [ + _ExampleCard( + label: 'Single (standalone)', + child: _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.start, + text: 'A standalone message', + ), + ), + _ExampleCard( + label: 'Incoming stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + crossAlign: CrossAxisAlignment.start, + text: 'First message in group', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.middle, + crossAlign: CrossAxisAlignment.start, + text: 'Middle message', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + crossAlign: CrossAxisAlignment.start, + text: 'Last message in group', + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.top, + crossAlign: CrossAxisAlignment.end, + text: 'First outgoing message', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.middle, + crossAlign: CrossAxisAlignment.end, + text: 'Middle outgoing', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.bottom, + crossAlign: CrossAxisAlignment.end, + text: 'Last outgoing message', + ), + ], + ), + ), + ], + ); + } +} + +class _ConversationSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const _Section( + label: 'CONVERSATION', + description: 'A realistic exchange showing how alignment and stack position combine.', + children: [ + _ExampleCard( + label: 'Mixed thread', + child: Column( + spacing: 2, + children: [ + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + crossAlign: CrossAxisAlignment.start, + text: 'Hey, are you free this weekend?', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + crossAlign: CrossAxisAlignment.start, + text: 'We could go hiking 🏔️', + ), + SizedBox(height: 8), + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.end, + text: 'Sounds great! Let me check my schedule.', + ), + SizedBox(height: 8), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.start, + text: 'Perfect, let me know! 👍', + ), + ], + ), + ), + ], + ); + } +} + +class _StyleOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'STYLE OVERRIDE', + description: + 'Pass a custom StreamMessageBubbleStyle to override ' + 'individual properties while still using placement scope.', + children: [ + _ExampleCard( + label: 'Stadium shape with large padding', + child: StreamMessageBubble( + style: StreamMessageBubbleStyle.from( + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + child: StreamMessageText('Custom shape!'), + ), + ), + _ExampleCard( + label: 'Beveled rectangle', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: StreamMessageBubble( + style: StreamMessageBubbleStyle.from( + shape: const BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + padding: const EdgeInsets.all(16), + ), + child: StreamMessageText('Beveled corners'), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _PlacedBubble extends StatelessWidget { + const _PlacedBubble({ + required this.alignment, + required this.stackPosition, + required this.crossAlign, + required this.text, + }); + + final StreamMessageAlignment alignment; + final StreamMessageStackPosition stackPosition; + final CrossAxisAlignment crossAlign; + final String text; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacementData( + alignment: alignment, + stackPosition: stackPosition, + ); + final isDefault = placement == const StreamMessagePlacementData(); + + Widget child = StreamMessageBubble(child: StreamMessageText(text)); + if (!isDefault) { + child = StreamMessagePlacement( + placement: placement, + child: child, + ); + } + + return Align( + alignment: crossAlign == CrossAxisAlignment.end + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: child, + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + }); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_content.dart b/apps/design_system_gallery/lib/components/message/stream_message_content.dart new file mode 100644 index 0000000..c27eb0f --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_content.dart @@ -0,0 +1,775 @@ +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: StreamMessageContent, + path: '[Components]/Message', +) +Widget buildStreamMessageContentPlayground(BuildContext context) { + final text = context.knobs.string( + label: 'Message Text', + initialValue: + 'Has anyone tried the new Flutter update? ' + 'The performance improvements are amazing!', + ); + + final showHeader = context.knobs.boolean( + label: 'Show Header', + description: 'Toggle the reminder annotation header.', + ); + + final showReplies = context.knobs.boolean( + label: 'Show Replies', + description: 'Toggle the reply indicator with avatars.', + ); + + final showReactions = context.knobs.boolean( + label: 'Show Reactions', + description: 'Wrap the bubble and replies with reactions.', + ); + + final reactionType = showReactions + ? context.knobs.object.dropdown( + label: 'Reaction Type', + options: StreamReactionsType.values, + initialOption: StreamReactionsType.segmented, + labelBuilder: (v) => v.name, + description: 'Segmented shows individual chips, clustered groups them.', + ) + : StreamReactionsType.segmented; + + final reactionCount = showReactions + ? context.knobs.int.slider( + label: 'Reaction Count', + initialValue: 3, + min: 1, + max: _allReactions.length, + description: 'Number of distinct reaction types to show.', + ) + : 0; + + final reactionPosition = showReactions + ? context.knobs.object.dropdown( + label: 'Reaction Position', + options: StreamReactionsPosition.values, + initialOption: StreamReactionsPosition.header, + labelBuilder: (v) => v.name, + description: 'Where reactions sit relative to the bubble.', + ) + : StreamReactionsPosition.footer; + + final reactionOverlap = reactionPosition == StreamReactionsPosition.header; + + final showFooter = context.knobs.boolean( + label: 'Show Footer', + initialValue: true, + description: 'Toggle the metadata footer.', + ); + + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final palette = colorScheme.avatarPalette; + + final emojiCount = StreamMessageText.emojiOnlyCount(text); + final isEmojiOnly = emojiCount != null && emojiCount <= 3; + + final messageText = StreamMessageText(text); + final Widget messageWidget = isEmojiOnly ? messageText : StreamMessageBubble(child: messageText); + + final replies = showReplies + ? StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(3, palette), + showConnector: !isEmojiOnly, + ) + : null; + + Widget body; + if (showReactions) { + final items = _allReactions.take(reactionCount).toList(); + + Widget buildReactions({required Widget child}) => switch (reactionType) { + StreamReactionsType.segmented => StreamReactions.segmented( + items: items, + position: reactionPosition, + overlap: reactionOverlap, + alignment: reactionOverlap ? .end : .start, + indent: reactionOverlap ? 8 : null, + onPressed: () {}, + child: child, + ), + StreamReactionsType.clustered => StreamReactions.clustered( + items: items, + position: reactionPosition, + overlap: reactionOverlap, + alignment: reactionOverlap ? .end : .start, + indent: reactionOverlap ? 8 : null, + onPressed: () {}, + child: child, + ), + }; + + if (reactionOverlap) { + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + buildReactions(child: messageWidget), + ?replies, + ], + ); + } else { + body = buildReactions( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [messageWidget, ?replies], + ), + ); + } + } else { + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [messageWidget, ?replies], + ); + } + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: StreamMessageContent( + header: showHeader + ? StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ) + : null, + footer: showFooter + ? StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ) + : null, + child: body, + ), + ), + ); +} + +const _allReactions = [ + StreamReactionsItem(emoji: Text('👍'), count: 8), + StreamReactionsItem(emoji: Text('❤'), count: 14), + StreamReactionsItem(emoji: Text('😂'), count: 5), + StreamReactionsItem(emoji: Text('🔥'), count: 3), + StreamReactionsItem(emoji: Text('🎉'), count: 2), + StreamReactionsItem(emoji: Text('👏'), count: 7), + StreamReactionsItem(emoji: Text('😮')), + StreamReactionsItem(emoji: Text('🙏'), count: 4), +]; + +const _sampleImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', +]; + +const _sampleInitials = ['AB', 'CD', 'EF']; + +List _sampleAvatars(int count, List palette) { + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: _sampleImages[i % _sampleImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_sampleInitials[i % _sampleInitials.length]), + ), + ]; +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageContent, + path: '[Components]/Message', +) +Widget buildStreamMessageContentShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _SlotCombinationsSection(), + _ReactionVariantsSection(), + _FullCompositionSection(), + _EmojiOnlySection(), + _MinimalSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _SlotCombinationsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'SLOT COMBINATIONS', + description: + 'StreamMessageContent stacks header, child, and footer. ' + 'Each slot is optional except child.', + children: [ + _ExampleCard( + label: 'Header + child + footer', + child: StreamMessageContent( + header: StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamMessageBubble( + child: StreamMessageText('Has anyone tried the new Flutter update?'), + ), + ), + ), + _ExampleCard( + label: 'Child + footer (edited)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + child: StreamMessageBubble( + child: StreamMessageText('A message with just metadata below.'), + ), + ), + ), + _ExampleCard( + label: 'Header + child (no footer)', + child: StreamMessageContent( + header: StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBookmark), + label: const Text('Saved for later'), + ), + child: StreamMessageBubble( + child: StreamMessageText('Saved message, no footer.'), + ), + ), + ), + _ExampleCard( + label: 'Children with replies + footer', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:43'), + username: const Text('Bob'), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageBubble( + child: StreamMessageText('Let me know what you think!'), + ), + StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ReactionVariantsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'REACTION VARIANTS', + description: + 'Reactions can overlap or sit below the bubble, and be ' + 'positioned at the header or footer of the child.', + children: [ + _ExampleCard( + label: 'Overlapping (header)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:44'), + username: const Text('Alice'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + StreamReactionsItem(emoji: Text('❤'), count: 2), + ], + position: StreamReactionsPosition.header, + alignment: StreamReactionsAlignment.end, + indent: 8, + child: StreamMessageBubble( + child: StreamMessageText('Reactions overlap the bubble edge.'), + ), + ), + ), + ), + _ExampleCard( + label: 'Non-overlapping', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:45'), + username: const Text('Bob'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('😂'), count: 5), + StreamReactionsItem(emoji: Text('🔥'), count: 3), + StreamReactionsItem(emoji: Text('🎉'), count: 2), + ], + overlap: false, + child: StreamMessageBubble( + child: StreamMessageText('Reactions with a gap below.'), + ), + ), + ), + ), + _ExampleCard( + label: 'Header position', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:46'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👏'), count: 7), + ], + position: StreamReactionsPosition.header, + alignment: StreamReactionsAlignment.end, + indent: 8, + child: StreamMessageBubble( + child: StreamMessageText('Reaction sits above the bubble.'), + ), + ), + ), + ), + _ExampleCard( + label: 'Many reactions (wrapping)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:47'), + username: const Text('Charlie'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 8), + StreamReactionsItem(emoji: Text('❤'), count: 14), + StreamReactionsItem(emoji: Text('😂'), count: 5), + StreamReactionsItem(emoji: Text('🔥'), count: 3), + StreamReactionsItem(emoji: Text('🎉'), count: 2), + StreamReactionsItem(emoji: Text('👏'), count: 7), + ], + overlap: false, + child: StreamMessageBubble( + child: StreamMessageText('Short text.'), + ), + ), + ), + ), + ], + ); + } +} + +class _FullCompositionSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'FULL COMPOSITION', + description: + 'All slots populated with annotations, reactions, replies, ' + 'and metadata demonstrating the intended layout.', + children: [ + _ExampleCard( + label: 'Incoming — all slots', + child: Align( + alignment: .centerLeft, + child: StreamMessageContent( + header: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconPin), + label: const Text('Pinned'), + ), + StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + ], + ), + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + StreamReactionsItem(emoji: Text('😂'), count: 1), + StreamReactionsItem(emoji: Text('❤'), count: 5), + ], + overlap: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageBubble( + child: StreamMessageText( + 'This message has multiple annotations, ' + 'reactions, a reply indicator, and full metadata.', + ), + ), + StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ], + ), + ), + ), + ), + ), + _ExampleCard( + label: 'Outgoing — reactions + status', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamReactions.segmented( + alignment: .end, + overlap: false, + items: const [StreamReactionsItem(emoji: Text('👍'), count: 2)], + child: StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _EmojiOnlySection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: + 'Messages with 1–3 emojis render without a bubble. ' + 'Shown with reactions, replies, and metadata.', + children: [ + _ExampleCard( + label: 'Single emoji + reactions + footer', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:48'), + username: const Text('Alice'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('❤'), count: 4), + StreamReactionsItem(emoji: Text('😂'), count: 2), + ], + overlap: false, + child: StreamMessageText('👋'), + ), + ), + ), + _ExampleCard( + label: 'Two emojis + replies (no connector)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:49'), + username: const Text('Bob'), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageText('❤️🔥'), + StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, + ), + ], + ), + ), + ), + _ExampleCard( + label: 'Three emojis + reactions + replies', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:50'), + username: const Text('Charlie'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('🔥'), count: 3), + ], + overlap: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageText('🎉👏🔥'), + StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Outgoing emoji + reactions', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:51'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamReactions.segmented( + alignment: .end, + overlap: false, + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 5), + ], + child: StreamMessageText('😂'), + ), + ), + ), + ), + ], + ); + } +} + +class _MinimalSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'MINIMAL', + description: 'Only the required child slot — no header or footer.', + children: [ + _ExampleCard( + label: 'Bubble only', + child: StreamMessageContent( + child: StreamMessageBubble( + child: StreamMessageText('Just a bubble, nothing else.'), + ), + ), + ), + _ExampleCard( + label: 'Bubble + footer only', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:50'), + ), + child: StreamMessageBubble( + child: StreamMessageText('Hey!'), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + }); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart new file mode 100644 index 0000000..df4c76a --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart @@ -0,0 +1,553 @@ +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: StreamMessageMetadata, + path: '[Components]/Message', +) +Widget buildStreamMessageMetadataPlayground(BuildContext context) { + final timestamp = context.knobs.string( + label: 'Timestamp', + initialValue: '09:41', + description: 'The timestamp text displayed in the metadata row.', + ); + + final showStatus = context.knobs.boolean( + label: 'Show Status', + initialValue: true, + description: 'Whether to show a delivery status icon.', + ); + + final statusOption = context.knobs.object.dropdown<_StatusOption>( + label: 'Status', + options: _StatusOption.values, + initialOption: _StatusOption.delivered, + labelBuilder: (option) => option.label, + description: 'The delivery status to display.', + ); + + final showUsername = context.knobs.boolean( + label: 'Show Username', + initialValue: true, + description: 'Whether to show the sender username.', + ); + + final username = context.knobs.string( + label: 'Username', + initialValue: 'Alice', + description: 'The username text.', + ); + + final showEdited = context.knobs.boolean( + label: 'Show Edited', + initialValue: true, + description: 'Whether to show the edited indicator.', + ); + + final editedText = context.knobs.string( + label: 'Edited Text', + initialValue: 'Edited', + description: 'The edited indicator text.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 8, + max: 24, + divisions: 24, + description: 'Gap between main elements. Overrides theme when set.', + ); + + final minHeight = context.knobs.double.slider( + label: 'Min Height', + initialValue: 24, + min: 16, + max: 48, + divisions: 32, + description: 'Minimum height of the metadata row.', + ); + + final accentPrimary = context.streamColorScheme.accentPrimary; + + Widget child = StreamMessageMetadata( + timestamp: Text(timestamp), + status: showStatus ? Icon(statusOption.iconData) : null, + username: showUsername ? Text(username) : null, + edited: showEdited ? Text(editedText) : null, + style: StreamMessageMetadataStyle.from( + spacing: spacing, + minHeight: minHeight, + ), + ); + + if (showStatus && statusOption == _StatusOption.read) { + child = StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from(statusColor: accentPrimary), + ), + child: child, + ); + } + + return Center(child: child); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageMetadata, + path: '[Components]/Message', +) +Widget buildStreamMessageMetadataShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _SlotCombinationsSection(), + _DeliveryStatusSection(), + _RealWorldSection(), + _ThemeOverrideSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _SlotCombinationsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'SLOT COMBINATIONS', + description: 'Each slot can be shown or hidden independently.', + children: [ + _ExampleCard( + label: 'Timestamp only', + child: StreamMessageMetadata(timestamp: const Text('09:41')), + ), + _ExampleCard( + label: 'Timestamp + username', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + ), + ), + _ExampleCard( + label: 'Timestamp + status', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Timestamp + edited', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + edited: const Text('Edited'), + ), + ), + _ExampleCard( + label: 'All slots', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + username: const Text('Alice'), + edited: const Text('Edited'), + ), + ), + ], + ); + } +} + +class _DeliveryStatusSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final accentPrimary = context.streamColorScheme.accentPrimary; + + return _Section( + label: 'DELIVERY STATUS', + description: 'Status progresses from sending → sent → delivered → read.', + children: [ + _ExampleCard( + label: 'Sending', + subtitle: 'Clock icon while message is in transit.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconClock), + ), + ), + _ExampleCard( + label: 'Sent', + subtitle: 'Single checkmark after server acknowledgement.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Delivered', + subtitle: 'Double checkmark when received by recipient.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Read', + subtitle: 'Accent-colored double checkmark when read.', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from(statusColor: accentPrimary), + ), + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Metadata shown beneath message bubbles.', + children: [ + _ExampleCard( + label: 'Incoming message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Has anyone tried the new Flutter update?'), + ), + StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Bob'), + ), + ], + ), + ), + _ExampleCard( + label: 'Incoming message (edited)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('I think the new APIs are much better now'), + ), + StreamMessageMetadata( + timestamp: const Text('09:38'), + username: const Text('Charlie'), + edited: const Text('Edited'), + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing message (sending)', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Let me check that real quick'), + ), + StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconClock), + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Outgoing message (read)', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), + ), + StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from( + statusColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageMetadata( + timestamp: const Text('09:40'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Outgoing message (read + edited)', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Actually, let me rephrase that'), + ), + StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from( + statusColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageMetadata( + timestamp: const Text('09:40'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance overrides via StreamMessageItemTheme.', + children: [ + _ExampleCard( + label: 'Custom username color', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from( + usernameColor: Colors.deepPurple, + ), + ), + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + ), + ), + ), + _ExampleCard( + label: 'Custom spacing', + subtitle: 'Wider gap (16) between elements.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconCheckmark1Small), + edited: const Text('Edited'), + style: StreamMessageMetadataStyle.from(spacing: 16), + ), + ), + _ExampleCard( + label: 'Compact', + subtitle: 'Tighter spacing (4) and smaller min height (20).', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + style: StreamMessageMetadataStyle.from(spacing: 4, minHeight: 20), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + child, + ], + ), + ); + } +} + +// ============================================================================= +// Status Icon Options (for Playground knobs) +// ============================================================================= + +enum _StatusOption { + sending('Sending', StreamIconData.iconClock), + sent('Sent', StreamIconData.iconCheckmark1Small), + delivered('Delivered', StreamIconData.iconDoupleCheckmark1Small), + read('Read', StreamIconData.iconDoupleCheckmark1Small) + ; + + const _StatusOption(this.label, this.iconData); + + final String label; + final IconData iconData; +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart new file mode 100644 index 0000000..0b7402d --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart @@ -0,0 +1,660 @@ +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: StreamMessageReplies, + path: '[Components]/Message', +) +Widget buildStreamMessageRepliesPlayground(BuildContext context) { + final showLabel = context.knobs.boolean( + label: 'Show Label', + initialValue: true, + description: 'Whether to show the reply count label.', + ); + + final labelText = context.knobs.string( + label: 'Label Text', + initialValue: '3 replies', + description: 'The reply count text.', + ); + + final avatarCount = context.knobs.double.slider( + label: 'Avatar Count', + initialValue: 3, + max: 6, + divisions: 6, + description: 'Number of participant avatars.', + ); + + final maxAvatars = context.knobs.double.slider( + label: 'Max Avatars', + initialValue: 3, + min: 2, + max: 5, + divisions: 3, + description: 'Max visible avatars before +N overflow badge.', + ); + + final avatarSize = context.knobs.object.dropdown( + label: 'Avatar Size', + options: StreamAvatarStackSize.values, + initialOption: StreamAvatarStackSize.sm, + labelBuilder: (v) => v.name, + description: 'Size of each avatar in the stack.', + ); + + final showConnector = context.knobs.boolean( + label: 'Show Connector', + initialValue: true, + description: 'Whether to show the connector widget.', + ); + + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamMessageAlignment.values, + initialOption: StreamMessageAlignment.start, + labelBuilder: (v) => v.name, + description: 'Semantic element order in the row.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 8, + max: 24, + divisions: 24, + description: 'Gap between elements. Overrides theme when set.', + ); + + final verticalPadding = context.knobs.double.slider( + label: 'Vertical Padding', + initialValue: 4, + max: 24, + divisions: 24, + description: 'Vertical padding around the row content.', + ); + + final clipOption = context.knobs.object.dropdown<_ClipOption>( + label: 'Clip Behavior', + options: _ClipOption.values, + initialOption: _ClipOption.none, + labelBuilder: (v) => v.label, + description: 'How to clip overflow. Theme default clips for single/bottom, none for top/middle.', + ); + + final palette = context.streamColorScheme.avatarPalette; + + return Center( + child: StreamMessageReplies( + label: showLabel ? Text(labelText) : null, + avatars: _sampleAvatars(avatarCount.toInt(), palette), + avatarSize: avatarSize, + maxAvatars: maxAvatars.toInt(), + showConnector: showConnector, + alignment: alignment, + clipBehavior: clipOption.clip, + style: StreamMessageRepliesStyle.from( + spacing: spacing, + padding: EdgeInsets.symmetric(vertical: verticalPadding), + ), + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Tapped'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageReplies, + path: '[Components]/Message', +) +Widget buildStreamMessageRepliesShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _SlotCombinationsSection(), + _AlignmentSection(), + _ConnectorOverflowSection(), + _RealWorldSection(), + _EmojiOnlySection(), + _ThemeOverrideSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _SlotCombinationsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'SLOT COMBINATIONS', + description: 'Each slot can be shown or hidden independently.', + children: [ + _ExampleCard( + label: 'Label only', + child: StreamMessageReplies(label: const Text('3 replies')), + ), + _ExampleCard( + label: 'Avatars only', + child: StreamMessageReplies(avatars: _sampleAvatars(2, palette)), + ), + _ExampleCard( + label: 'Label + avatars', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(3, palette), + ), + ), + _ExampleCard( + label: 'With connector', + child: StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(2, palette), + ), + ), + _ExampleCard( + label: 'All slots with overflow', + subtitle: '5 avatars, max 2 — shows +3 badge.', + child: StreamMessageReplies( + label: const Text('8 replies'), + avatars: _sampleAvatars(5, palette), + maxAvatars: 2, + ), + ), + ], + ); + } +} + +class _AlignmentSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'ALIGNMENT', + description: 'Controls element order. Start = [connector, avatars, label]; End = [label, avatars, connector].', + children: [ + _ExampleCard( + label: 'Start alignment (default)', + subtitle: 'Connector → avatars → label.', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + ), + ), + _ExampleCard( + label: 'End alignment', + subtitle: 'Label → avatars → connector.', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + alignment: StreamMessageAlignment.end, + ), + ), + ], + ); + } +} + +class _ConnectorOverflowSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'CONNECTOR OVERFLOW', + description: + 'The connector extends above the row bounds. By default, single/bottom positions clip the overflow while top/middle positions do not.', + children: [ + _ExampleCard( + label: 'Theme default (single → clipped)', + subtitle: 'Connector overflow is clipped at the row boundary.', + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.borderSubtle, width: 0.5), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + onTap: () {}, + ), + ), + ), + _ExampleCard( + label: 'Clip.none (explicit)', + subtitle: 'Connector paints outside the component bounds.', + childPadding: const EdgeInsets.only(top: 24), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.borderSubtle, width: 0.5), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + clipBehavior: Clip.none, + onTap: () {}, + ), + ), + ), + _ExampleCard( + label: 'Theme default (middle → unclipped)', + subtitle: 'Middle position leaves connector visible for stacked messages.', + childPadding: const EdgeInsets.only(top: 24), + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + stackPosition: StreamMessageStackPosition.middle, + ), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.borderSubtle, width: 0.5), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + onTap: () {}, + ), + ), + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Message bubbles with threaded replies beneath.', + children: [ + _ExampleCard( + label: 'Incoming message with replies', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageBubble( + child: StreamMessageText('Has anyone tried the new Flutter update?'), + ), + StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + onTap: () {}, + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing message with replies', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), + ), + StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + alignment: StreamMessageAlignment.end, + onTap: () {}, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Single reply, no connector', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageBubble( + child: StreamMessageText('Let me check that.'), + ), + StreamMessageReplies( + label: const Text('1 reply'), + avatars: _sampleAvatars(1, palette), + onTap: () {}, + ), + ], + ), + ), + ], + ); + } +} + +class _EmojiOnlySection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: 'Replies attached to emoji-only messages that render without a bubble.', + children: [ + _ExampleCard( + label: 'Single emoji with replies', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageText('👋'), + StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, + onTap: () {}, + ), + ], + ), + ), + _ExampleCard( + label: 'Two emojis, outgoing with replies', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageText('❤️🔥'), + StreamMessageReplies( + label: const Text('4 replies'), + avatars: _sampleAvatars(3, palette), + alignment: StreamMessageAlignment.end, + showConnector: false, + onTap: () {}, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Three emojis with replies', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageText('🎉👏🔥'), + StreamMessageReplies( + label: const Text('1 reply'), + avatars: _sampleAvatars(1, palette), + showConnector: false, + onTap: () {}, + ), + ], + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance overrides via StreamMessageItemTheme.', + children: [ + _ExampleCard( + label: 'Custom label color', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + replies: StreamMessageRepliesStyle.from( + labelColor: Colors.deepPurple, + ), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + ), + ), + ), + _ExampleCard( + label: 'Custom connector', + subtitle: 'Red connector with 3px stroke.', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + replies: StreamMessageRepliesStyle.from( + connectorColor: Colors.red, + connectorStrokeWidth: 3, + ), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + ), + ), + ), + _ExampleCard( + label: 'Custom spacing', + subtitle: 'Wider gap (16) between elements.', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + style: StreamMessageRepliesStyle.from(spacing: 16), + ), + ), + _ExampleCard( + label: 'Compact', + subtitle: 'Tighter spacing (4) and no padding.', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + style: StreamMessageRepliesStyle.from(spacing: 4, padding: EdgeInsets.zero), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets & Data +// ============================================================================= + +enum _ClipOption { + themeDefault(null, 'Theme default'), + none(Clip.none, 'none'), + hardEdge(Clip.hardEdge, 'hardEdge'), + antiAlias(Clip.antiAlias, 'antiAlias'), + antiAliasWithSaveLayer(Clip.antiAliasWithSaveLayer, 'antiAliasWithSaveLayer') + ; + + const _ClipOption(this.clip, this.label); + + final Clip? clip; + final String label; +} + +const _sampleImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=200', + 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=200', +]; + +const _sampleInitials = ['AB', 'CD', 'EF', 'GH', 'IJ']; + +List _sampleAvatars(int count, List palette) { + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: _sampleImages[i % _sampleImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_sampleInitials[i % _sampleInitials.length]), + ), + ]; +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + this.childPadding, + }); + + final String label; + final String? subtitle; + final Widget child; + final EdgeInsetsGeometry? childPadding; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + if (childPadding case final padding?) Padding(padding: padding, child: child) else child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_text.dart b/apps/design_system_gallery/lib/components/message/stream_message_text.dart new file mode 100644 index 0000000..96cbc16 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_text.dart @@ -0,0 +1,1107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; +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: StreamMessageText, + path: '[Components]/Message', +) +Widget buildStreamMessageTextPlayground(BuildContext context) { + // -- Widget props ----------------------------------------------------------- + + final selectable = context.knobs.boolean( + label: 'Selectable', + description: 'Whether text can be selected.', + ); + + final softLineBreak = context.knobs.boolean( + label: 'Soft Line Break', + description: 'Treat single newlines as hard line breaks.', + ); + + final fitContent = context.knobs.boolean( + label: 'Fit Content', + initialValue: true, + description: 'Size to fit content vs expand to parent.', + ); + + // -- Theme props ------------------------------------------------------------ + + final textColor = context.knobs.object.dropdown<_ColorOption>( + label: 'Text Color', + options: _ColorOption.values, + labelBuilder: (v) => v.label, + description: 'Override paragraph text color.', + ); + + final linkColor = context.knobs.object.dropdown<_ColorOption>( + label: 'Link Color', + options: _ColorOption.values, + labelBuilder: (v) => v.label, + description: 'Override link color.', + ); + + final mentionColor = context.knobs.object.dropdown<_ColorOption>( + label: 'Mention Color', + options: _ColorOption.values, + labelBuilder: (v) => v.label, + description: 'Override mention color.', + ); + + final style = StreamMessageTextStyle.from( + textColor: textColor.color, + linkColor: linkColor.color, + mentionColor: mentionColor.color, + ); + + return StreamMessageItemTheme( + data: StreamMessageItemThemeData(text: style), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + children: [ + for (final msg in _conversationMessages) + _ConversationMessage( + message: msg, + selectable: selectable, + softLineBreak: softLineBreak, + fitContent: fitContent, + ), + ], + ), + ); +} + +enum _ColorOption { + none('Default', null), + purple('Purple', Colors.purple), + red('Red', Colors.red), + green('Green', Colors.green), + orange('Orange', Colors.orange), + teal('Teal', Colors.teal) + ; + + const _ColorOption(this.label, this.color); + final String label; + final Color? color; +} + +// ============================================================================= +// Playground Helpers +// ============================================================================= + +class _ChatMessage { + const _ChatMessage({ + required this.text, + required this.alignment, + required this.stackPosition, + }); + + final String text; + final StreamMessageAlignment alignment; + final StreamMessageStackPosition stackPosition; +} + +const _conversationMessages = [ + _ChatMessage( + text: 'Hey [@Sarah](mention:sarah42), tried the new update?', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Not yet! Got a [link](https://flutter.dev)?', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'The `Impeller` improvements are **amazing**', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + ), + _ChatMessage( + text: 'Jank is _basically_ gone', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + ), + _ChatMessage( + text: '\u{1F44D}\u{1F525}', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: + '[@Mike](mention:mike789) asked about that pattern:\n\n' + '```dart\nvoid debounce(Duration d, VoidCallback fn) {\n' + ' Timer(d, fn);\n}\n```', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Clean! Will add it to shared utils', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: '> Always call `dispose()` to avoid leaks', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: '\u{1F680}', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Already done! Check [the PR](https://github.com/example/pr/42)', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'LGTM, merging now', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Btw [@Alice](mention:alice456) left a comment', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + ), + _ChatMessage( + text: 'Something about **null safety** migration', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + ), + _ChatMessage( + text: '\u{2764}\u{FE0F}\u{1F389}\u{1F60D}', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: "I'll take a look after lunch", + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + ), + _ChatMessage( + text: '\u{1F60E}\u{1F44D}\u{1F525}\u{2764}\u{FE0F}\u{1F389}', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + ), + _ChatMessage( + text: 'That deserves a \u{1F680}!', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), +]; + +class _ConversationMessage extends StatelessWidget { + const _ConversationMessage({ + required this.message, + required this.selectable, + required this.softLineBreak, + required this.fitContent, + }); + + final _ChatMessage message; + final bool selectable; + final bool softLineBreak; + final bool fitContent; + + @override + Widget build(BuildContext context) { + final isEnd = message.alignment == StreamMessageAlignment.end; + final placement = StreamMessagePlacementData( + alignment: message.alignment, + stackPosition: message.stackPosition, + ); + + final showGap = + message.stackPosition == StreamMessageStackPosition.single || + message.stackPosition == StreamMessageStackPosition.top; + + return Padding( + padding: EdgeInsets.only(top: showGap ? 12 : 2), + child: Align( + alignment: isEnd ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + child: StreamMessagePlacement( + placement: placement, + child: Builder( + builder: (context) { + final emojiCount = StreamMessageText.emojiOnlyCount(message.text); + final hideBubble = emojiCount != null && emojiCount <= 3; + + Widget text = StreamMessageText( + message.text, + selectable: selectable, + softLineBreak: softLineBreak, + fitContent: fitContent, + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + ); + + if (!hideBubble) { + text = StreamMessageBubble(child: text); + } + + return text; + }, + ), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageText, + path: '[Components]/Message', +) +Widget buildStreamMessageTextShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _MarkdownFeaturesSection(), + _EmojiSection(), + _ThemeOverridesSection(), + _RealWorldSection(), + _ExtensibilitySection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _MarkdownFeaturesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'MARKDOWN FEATURES', + description: 'Core markdown rendering capabilities.', + children: [ + _ExampleCard( + label: 'Inline formatting', + subtitle: 'Bold, italic, strikethrough, code.', + child: StreamMessageText( + '**Bold**, _italic_, ~~strikethrough~~, and `inline code`.', + ), + ), + _ExampleCard( + label: 'Links', + subtitle: 'Clickable hyperlinks.', + child: Builder( + builder: (context) => StreamMessageText( + 'Visit [Flutter](https://flutter.dev) or [Dart](https://dart.dev).', + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + ), + ), + ), + _ExampleCard( + label: 'Mentions', + subtitle: 'Styled mention links using the mention: protocol.', + child: Builder( + builder: (context) => StreamMessageText( + 'Hey [@Alice](mention:alice123), have you seen ' + "[@Bob's update](mention:bob456)?", + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + ), + ), + ), + _ExampleCard( + label: 'Headings', + subtitle: 'H1 through H6.', + child: StreamMessageText( + '# Heading 1\n## Heading 2\n### Heading 3\n' + '#### Heading 4\n##### Heading 5\n###### Heading 6', + ), + ), + _ExampleCard( + label: 'Lists', + subtitle: 'Ordered and unordered.', + child: StreamMessageText( + '- First item\n- Second item\n - Nested item\n\n' + '1. Step one\n2. Step two\n3. Step three', + ), + ), + _ExampleCard( + label: 'Blockquote', + subtitle: 'Quoted text with left border.', + child: StreamMessageText( + '> The best way to predict the future\n' + '> is to invent it.\n\n— Alan Kay', + ), + ), + _ExampleCard( + label: 'Code block', + subtitle: 'Fenced code with decoration.', + child: StreamMessageText( + "```dart\nvoid main() {\n print('Hello, world!');\n}\n```", + ), + ), + _ExampleCard( + label: 'Table', + subtitle: 'Bordered data table.', + child: StreamMessageText( + '| Feature | Status |\n|---------|--------|\n' + '| Markdown | Done |\n| Theming | Done |\n| Tests | Pending |', + ), + ), + _ExampleCard( + label: 'Horizontal rule', + child: StreamMessageText('Above the line\n\n---\n\nBelow the line'), + ), + ], + ); + } +} + +class _EmojiSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: + 'Messages containing only emojis render at larger sizes ' + 'without a bubble. Size scales by emoji count.', + children: [ + _ExampleCard( + label: '1 emoji — xxl (${StreamEmojiSize.xxl.value.toInt()}px)', + child: StreamMessageText('\u{1F680}'), + ), + _ExampleCard( + label: '2 emojis — xl (${StreamEmojiSize.xl.value.toInt()}px)', + child: StreamMessageText('\u{1F44D}\u{1F525}'), + ), + _ExampleCard( + label: '3 emojis — lg (${StreamEmojiSize.lg.value.toInt()}px)', + child: StreamMessageText('\u{2764}\u{FE0F}\u{1F389}\u{1F60D}'), + ), + _ExampleCard( + label: '4+ emojis — regular size', + child: StreamMessageBubble( + child: StreamMessageText( + '\u{1F60E}\u{1F44D}\u{1F525}\u{2764}\u{FE0F}\u{1F389}', + ), + ), + ), + _ExampleCard( + label: 'ZWJ family (1 grapheme)', + child: StreamMessageText('\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'), + ), + _ExampleCard( + label: 'Flag (1 grapheme)', + child: StreamMessageText('\u{1F1FA}\u{1F1F8}'), + ), + _ExampleCard( + label: 'Skin tone (1 grapheme)', + child: StreamMessageText('\u{1F44D}\u{1F3FD}'), + ), + _ExampleCard( + label: 'Mixed text + emoji — regular', + child: StreamMessageBubble( + child: StreamMessageText('Great job \u{1F44D}'), + ), + ), + ], + ); + } +} + +class _ThemeOverridesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance and placement-aware style overrides.', + children: [ + _ExampleCard( + label: 'Custom text color', + subtitle: 'Purple text via style override.', + child: StreamMessageText( + 'This text is purple via a style override.', + style: StreamMessageTextStyle.from(textColor: Colors.purple), + ), + ), + _ExampleCard( + label: 'Custom code block', + subtitle: 'Dark background with green monospace text via styleSheet.', + child: StreamMessageText( + '```\nconst greeting = "Hello!";\nconsole.log(greeting);\n```', + styleSheet: MarkdownStyleSheet( + codeblockDecoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(8), + ), + code: const TextStyle( + fontFamily: 'monospace', + color: Color(0xFF4EC9B0), + ), + ), + ), + ), + _ExampleCard( + label: 'Custom blockquote', + subtitle: 'Brand-colored left border via styleSheet.', + child: StreamMessageText( + '> Design is not just what it looks like\n> and feels like.\n' + '> Design is how it works.\n\n— Steve Jobs', + styleSheet: MarkdownStyleSheet( + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide(color: colorScheme.accentPrimary, width: 4), + ), + ), + ), + ), + ), + _ExampleCard( + label: 'Placement-aware styling', + subtitle: 'Different text colors for start vs end alignment.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + StreamMessagePlacement( + placement: const StreamMessagePlacementData(), + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + text: StreamMessageTextStyle( + textColor: StreamMessageStyleProperty.resolveWith((p) { + return p.alignment == StreamMessageAlignment.end ? Colors.white : Colors.black87; + }), + ), + ), + child: StreamMessageBubble( + child: StreamMessageText('Start-aligned message (dark text)'), + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData(alignment: StreamMessageAlignment.end), + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + text: StreamMessageTextStyle( + textColor: StreamMessageStyleProperty.resolveWith((p) { + return p.alignment == StreamMessageAlignment.end ? Colors.white : Colors.black87; + }), + ), + ), + child: StreamMessageBubble( + child: StreamMessageText('End-aligned message (white text)'), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'REAL-WORLD COMPOSITIONS', + description: + 'Message text inside full message layouts with bubbles, ' + 'metadata, annotations, replies, and reactions.', + children: [ + _ExampleCard( + label: 'Chat message with mention', + subtitle: 'Text in bubble with a mention and metadata.', + child: Builder( + builder: (context) => StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + ), + child: StreamMessageBubble( + child: StreamMessageText( + 'Hey [@Sarah](mention:sarah42), have you tried the ' + '**new Flutter update**? The performance ' + 'improvements are _amazing_!', + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + ), + ), + ), + ), + ), + _ExampleCard( + label: 'AI response', + subtitle: 'Rich markdown with code in bubble.', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:43'), + ), + child: StreamMessageBubble( + child: StreamMessageText( + '## Quick Sort in Dart\n\n' + "Here's an efficient implementation:\n\n" + '```dart\n' + 'List quickSort(List list) {\n' + ' if (list.length <= 1) return list;\n' + ' final pivot = list[list.length ~/ 2];\n' + ' final less = list.where((e) => e < pivot).toList();\n' + ' final equal = list.where((e) => e == pivot).toList();\n' + ' final greater = list.where((e) => e > pivot).toList();\n' + ' return [...quickSort(less), ...equal, ...quickSort(greater)];\n' + '}\n' + '```\n\n' + 'The time complexity is **O(n log n)** on average.', + ), + ), + ), + ), + _ExampleCard( + label: 'Pinned message with replies', + subtitle: 'Annotation, bubble, metadata, and reply indicator.', + child: StreamMessageContent( + header: StreamMessageAnnotation( + leading: Icon(context.streamIcons.pin), + label: const Text('Pinned by Alice'), + ), + footer: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageMetadata( + timestamp: const Text('08:30'), + ), + StreamMessageReplies( + label: const Text('4 replies'), + ), + ], + ), + child: StreamMessageBubble( + child: StreamMessageText( + 'Meeting agenda:\n\n' + '1. **Sprint review** — demo the new features\n' + '2. **Retro** — what went well / what to improve\n' + '3. **Planning** — next sprint priorities\n\n' + '> Please come prepared with your updates.', + ), + ), + ), + ), + _ExampleCard( + label: 'Message with reactions', + subtitle: 'Bubble with markdown text and reaction chips.', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('10:15'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('\u{1F680}'), count: 5), + StreamReactionsItem(emoji: Text('\u{1F44D}'), count: 3), + StreamReactionsItem(emoji: Text('\u{2764}\u{FE0F}'), count: 2), + ], + child: StreamMessageBubble( + child: StreamMessageText( + 'Just shipped the new **markdown component**!\n\n' + 'Features:\n' + '- Full GFM support\n' + '- Placement-aware theming\n' + '- Custom builder extensibility', + ), + ), + ), + ), + ), + ], + ); + } +} + +class _ExtensibilitySection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'EXTENSIBILITY', + description: + 'Demonstrates custom builders, syntax highlighting, ' + 'link handlers, and image builders.', + children: [ + _ExampleCard( + label: 'Custom syntax highlighter', + subtitle: 'Keyword-aware highlighting via SyntaxHighlighter.', + child: StreamMessageText( + '```dart\n' + "import 'dart:async';\n\n" + 'void main() async {\n' + ' final stream = Stream.periodic(\n' + ' const Duration(seconds: 1),\n' + ' (i) => i,\n' + ' );\n\n' + ' await for (final value in stream) {\n' + ' if (value > 5) break;\n' + " print('Tick: \$value');\n" + ' }\n' + '}\n' + '```', + syntaxHighlighter: _DemoSyntaxHighlighter(colorScheme), + ), + ), + _ExampleCard( + label: 'Custom code block builder', + subtitle: 'Code blocks with a copy button and language label.', + child: StreamMessageText( + '```dart\n' + 'class Greeting {\n' + ' final String name;\n' + ' Greeting(this.name);\n\n' + " String say() => 'Hello, \$name!';\n" + '}\n' + '```', + builders: {'pre': _CopyableCodeBlockBuilder(colorScheme)}, + ), + ), + _ExampleCard( + label: 'Custom link handler', + subtitle: 'Links with a snackbar preview on tap.', + child: Builder( + builder: (context) => StreamMessageText( + 'Check out [Flutter docs](https://flutter.dev) ' + 'or [Dart packages](https://pub.dev).', + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + ), + ), + ), + _ExampleCard( + label: 'Mention handler', + subtitle: 'Mentions and links with separate tap handling.', + child: Builder( + builder: (context) => StreamMessageText( + '[@Alice](mention:alice123) shared ' + '[this article](https://example.com) with ' + '[@Bob](mention:bob456).', + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + ), + ), + ), + _ExampleCard( + label: 'Custom image builder', + subtitle: 'Markdown images with rounded corners and placeholder.', + child: StreamMessageText( + '![Flutter logo](https://storage.googleapis.com/cms-storage-bucket/c823e53b3a1a7b0d36a9.png)', + imageBuilder: (uri, title, alt) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + uri.toString(), + width: 200, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => DecoratedBox( + decoration: BoxDecoration(color: colorScheme.backgroundSurface), + child: SizedBox( + width: 200, + height: 100, + child: Center( + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.textTertiary, + ), + ), + ), + ), + ), + ); + }, + ), + ), + _ExampleCard( + label: 'MarkdownStyleSheet escape hatch', + subtitle: 'Fine-grained styling via the styleSheet prop.', + child: StreamMessageText( + '# Custom heading\n\n' + 'Paragraph with **bold** and a `code span`.\n\n' + '> A styled blockquote.', + styleSheet: MarkdownStyleSheet( + h1: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: colorScheme.accentPrimary, + ), + blockquoteDecoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.1), + border: Border( + left: BorderSide(color: colorScheme.accentPrimary, width: 4), + ), + ), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Demo Syntax Highlighter +// ============================================================================= + +class _DemoSyntaxHighlighter extends SyntaxHighlighter { + _DemoSyntaxHighlighter(this._colorScheme); + + final StreamColorScheme _colorScheme; + + static final _keywords = RegExp( + r'\b(import|void|main|async|await|for|final|const|if|break|in|return|class|extends|with|mixin|enum|switch|case|default|try|catch|throw|new|this|super)\b', + ); + static final _strings = RegExp("'[^']*'"); + static final _comments = RegExp(r'//.*$', multiLine: true); + static final _types = RegExp( + r'\b(String|int|double|bool|List|Map|Set|Future|Stream|Duration|void|dynamic|var|Object)\b', + ); + + @override + TextSpan format(String source) { + final spans = []; + final matches = <_SyntaxMatch>[]; + + for (final m in _comments.allMatches(source)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.comment)); + } + for (final m in _strings.allMatches(source)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.string)); + } + for (final m in _keywords.allMatches(source)) { + if (!matches.any((s) => m.start >= s.start && m.start < s.end)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.keyword)); + } + } + for (final m in _types.allMatches(source)) { + if (!matches.any((s) => m.start >= s.start && m.start < s.end)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.type)); + } + } + + matches.sort((a, b) => a.start.compareTo(b.start)); + + var lastEnd = 0; + for (final match in matches) { + if (match.start > lastEnd) { + spans.add(TextSpan(text: source.substring(lastEnd, match.start))); + } + spans.add( + TextSpan( + text: source.substring(match.start, match.end), + style: _styleFor(match.type), + ), + ); + lastEnd = match.end; + } + if (lastEnd < source.length) { + spans.add(TextSpan(text: source.substring(lastEnd))); + } + + return TextSpan(children: spans); + } + + TextStyle _styleFor(_MatchType type) => switch (type) { + _MatchType.keyword => TextStyle(color: _colorScheme.accentPrimary, fontWeight: FontWeight.bold), + _MatchType.string => TextStyle(color: Colors.green.shade700), + _MatchType.comment => TextStyle(color: _colorScheme.textTertiary, fontStyle: FontStyle.italic), + _MatchType.type => TextStyle(color: Colors.teal.shade600), + }; +} + +enum _MatchType { keyword, string, comment, type } + +class _SyntaxMatch { + const _SyntaxMatch(this.start, this.end, this.type); + final int start; + final int end; + final _MatchType type; +} + +// ============================================================================= +// Custom Code Block Builder +// ============================================================================= + +class _CopyableCodeBlockBuilder extends MarkdownElementBuilder { + _CopyableCodeBlockBuilder(this._colorScheme); + + final StreamColorScheme _colorScheme; + + @override + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final code = element.textContent.trimRight(); + final language = element.attributes['class']?.replaceFirst('language-', '') ?? ''; + + return _CopyableCodeBlock( + code: code, + language: language, + colorScheme: _colorScheme, + ); + } +} + +class _CopyableCodeBlock extends StatefulWidget { + const _CopyableCodeBlock({ + required this.code, + required this.language, + required this.colorScheme, + }); + + final String code; + final String language; + final StreamColorScheme colorScheme; + + @override + State<_CopyableCodeBlock> createState() => _CopyableCodeBlockState(); +} + +class _CopyableCodeBlockState extends State<_CopyableCodeBlock> { + var _copied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.code)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + setState(() => _copied = false); + } + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + color: cs.backgroundSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: cs.borderSubtle.withValues(alpha: 0.3), + borderRadius: const BorderRadius.vertical(top: Radius.circular(7)), + ), + child: Row( + children: [ + if (widget.language.isNotEmpty) + Text( + widget.language, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: cs.textSecondary, + ), + ), + const Spacer(), + GestureDetector( + onTap: _copy, + child: Row( + spacing: 4, + children: [ + Icon( + _copied ? Icons.check : Icons.copy, + size: 14, + color: _copied ? Colors.green : cs.textTertiary, + ), + Text( + _copied ? 'Copied!' : 'Copy', + style: TextStyle( + fontSize: 12, + color: _copied ? Colors.green : cs.textTertiary, + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + widget.code, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: cs.textPrimary, + ), + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +void _showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle(fontSize: 13, color: colorScheme.textTertiary), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle(fontSize: 12, color: colorScheme.textTertiary), + ), + ], + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_widget.dart b/apps/design_system_gallery/lib/components/message/stream_message_widget.dart new file mode 100644 index 0000000..e4e8fe0 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_widget.dart @@ -0,0 +1,1009 @@ +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: StreamMessageWidget, + path: '[Components]/Message', +) +Widget buildStreamMessageWidgetPlayground(BuildContext context) { + // -- Widget props ----------------------------------------------------------- + + final text = context.knobs.string( + label: 'Message Text', + initialValue: + 'Has anyone tried the new Flutter update? ' + 'The performance improvements are amazing!', + description: 'The text content inside the message bubble.', + ); + + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamMessageAlignment.values, + labelBuilder: (v) => v.name, + description: 'Start (incoming) or end (outgoing).', + ); + + final messageCount = context.knobs.int.slider( + label: 'Message Count', + initialValue: 1, + min: 1, + max: 5, + description: 'Number of messages in the stack. Positions are assigned automatically.', + ); + + final channelKind = context.knobs.object.dropdown( + label: 'Channel Kind', + options: StreamChannelKind.values, + initialOption: StreamChannelKind.group, + labelBuilder: (v) => v.name, + description: 'Direct (1-to-1) hides avatars; group shows them.', + ); + + // -- Content slots ---------------------------------------------------------- + + final showHeader = context.knobs.boolean( + label: 'Show Header', + description: 'Toggle the annotation header.', + ); + + final showReplies = context.knobs.boolean( + label: 'Show Replies', + description: 'Toggle the reply indicator with avatars.', + ); + + final showReactions = context.knobs.boolean( + label: 'Show Reactions', + description: 'Wrap the bubble with reactions.', + ); + + final reactionCount = showReactions + ? context.knobs.int.slider( + label: 'Reaction Count', + initialValue: 3, + min: 1, + max: _allReactions.length, + description: 'Number of distinct reaction types to show.', + ) + : 0; + + final reactionPosition = showReactions + ? context.knobs.object.dropdown( + label: 'Reaction Position', + options: StreamReactionsPosition.values, + initialOption: StreamReactionsPosition.header, + labelBuilder: (v) => v.name, + description: 'Where reactions sit relative to the bubble.', + ) + : StreamReactionsPosition.footer; + + final reactionOverlap = + showReactions && + context.knobs.boolean( + label: 'Reaction Overlap', + initialValue: true, + description: 'Whether reactions overlap the bubble edge.', + ); + + final showFooter = context.knobs.boolean( + label: 'Show Footer', + initialValue: true, + description: 'Toggle the metadata footer.', + ); + + // -- Build ------------------------------------------------------------------ + + final textTheme = context.streamTextTheme; + final palette = context.streamColorScheme.avatarPalette; + + final crossAlign = switch (alignment) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + + Widget buildBody(String messageText) { + final textWidget = StreamMessageText(messageText); + final eCount = StreamMessageText.emojiOnlyCount(messageText); + final emoji = eCount != null && eCount <= 3; + final Widget messageBody = emoji ? textWidget : StreamMessageBubble(child: textWidget); + + final replies = showReplies + ? StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(3, palette), + showConnector: !emoji, + onTap: () {}, + ) + : null; + + Widget body = Column( + crossAxisAlignment: crossAlign, + mainAxisSize: MainAxisSize.min, + children: [messageBody, ?replies], + ); + + if (showReactions) { + final items = _allReactions.take(reactionCount).toList(); + + Widget buildReactions({required Widget child}) => StreamReactions.segmented( + items: items, + position: reactionPosition, + overlap: reactionOverlap, + onPressed: () {}, + child: child, + ); + + if (reactionOverlap) { + body = Column( + crossAxisAlignment: crossAlign, + mainAxisSize: MainAxisSize.min, + children: [ + buildReactions(child: messageBody), + ?replies, + ], + ); + } else { + body = buildReactions(child: body); + } + } + + return body; + } + + StreamMessageStackPosition stackPositionFor(int index, int count) { + if (count == 1) return StreamMessageStackPosition.single; + if (index == 0) return StreamMessageStackPosition.top; + if (index == count - 1) return StreamMessageStackPosition.bottom; + return StreamMessageStackPosition.middle; + } + + final avatar = StreamAvatar( + imageUrl: _avatarImages[0], + backgroundColor: palette[0].backgroundColor, + foregroundColor: palette[0].foregroundColor, + placeholder: (context) => const Text('AJ'), + ); + + final messages = [ + for (var i = 0; i < messageCount; i++) + StreamMessageWidget( + alignment: alignment, + stackPosition: stackPositionFor(i, messageCount), + channelKind: channelKind, + onTap: () => _showSnack(context, 'Message tapped'), + onLongPress: () => _showSnack(context, 'Message long-pressed'), + leading: avatar, + child: i == messageCount - 1 + ? StreamMessageContent( + header: showHeader + ? StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ) + : null, + footer: showFooter + ? StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ) + : null, + child: buildBody(text), + ) + : StreamMessageContent( + child: StreamMessageBubble( + child: StreamMessageText(_stackMessages[i % _stackMessages.length]), + ), + ), + ), + ]; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 2, + children: messages, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageWidget, + path: '[Components]/Message', +) +Widget buildStreamMessageWidgetShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: const SingleChildScrollView( + padding: EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _AlignmentSection(), + _StackPositionsSection(), + _ChannelKindSection(), + _VisibilitySection(), + _FullCompositionSection(), + _EmojiOnlySection(), + _ConversationSection(), + _ThemeOverrideSection(), + _MinimalSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _AlignmentSection extends StatelessWidget { + const _AlignmentSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'ALIGNMENT', + description: + 'The leading avatar is placed at the start or end of the row ' + 'depending on alignment. Descendants receive placement context.', + children: [ + _ExampleCard( + label: 'Start (incoming)', + child: _MessageItem( + avatarIndex: 0, + text: 'Has anyone tried the new Flutter update?', + timestamp: '09:41', + username: 'Alice', + ), + ), + _ExampleCard( + label: 'End (outgoing)', + child: _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Sure, I can help with that!', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Start — no avatar', + child: _MessageItem( + text: 'A message without a leading avatar.', + timestamp: '09:43', + ), + ), + ], + ); + } +} + +class _StackPositionsSection extends StatelessWidget { + const _StackPositionsSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'STACK POSITIONS', + description: + 'In a consecutive group, only the bottom message shows the ' + 'avatar. Middle and top messages hide it while preserving spacing. ' + 'This is driven by the default leadingVisibility in the theme.', + children: [ + _ExampleCard( + label: 'Incoming stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _MessageItem( + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 0, + text: 'The keynote was incredible this year', + ), + _MessageItem( + stackPosition: StreamMessageStackPosition.middle, + avatarIndex: 0, + text: 'Especially the Impeller demo', + ), + _MessageItem( + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 0, + text: 'Did you catch the part about Wasm support?', + timestamp: '09:41', + username: 'Alice', + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 1, + text: 'Yes! The performance charts were wild', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.middle, + avatarIndex: 1, + text: '60fps on low-end devices', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 1, + text: "Can't wait to try it in our app", + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + ], + ); + } +} + +class _ChannelKindSection extends StatelessWidget { + const _ChannelKindSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'CHANNEL KIND', + description: + 'In direct (1-to-1) channels, avatars are hidden by default since ' + 'the sender is always known. In group channels, avatars are shown ' + 'for identification.', + children: [ + _ExampleCard( + label: 'Group channel — avatars visible', + child: Column( + spacing: 2, + children: [ + _MessageItem( + avatarIndex: 0, + text: 'In a group channel, avatars help identify the sender.', + timestamp: '09:41', + username: 'Alice', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Makes sense for multi-participant chats!', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + _ExampleCard( + label: 'Direct channel — avatars hidden', + child: Column( + spacing: 2, + children: [ + _MessageItem( + channelKind: StreamChannelKind.direct, + avatarIndex: 0, + text: 'In a direct channel, you already know who is talking.', + timestamp: '09:41', + username: 'Alice', + ), + _MessageItem( + channelKind: StreamChannelKind.direct, + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'So avatars are removed to save space.', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + ], + ); + } +} + +class _VisibilitySection extends StatelessWidget { + const _VisibilitySection(); + + @override + Widget build(BuildContext context) { + return _Section( + label: 'LEADING VISIBILITY', + description: + 'Leading visibility is theme-driven via StreamMessageItemTheme. ' + 'It supports three modes: visible (shown), hidden (space reserved), ' + 'and gone (no space).', + children: [ + const _ExampleCard( + label: 'Visible — avatar fully shown (default)', + child: _MessageItem( + avatarIndex: 0, + text: 'Avatar is visible.', + timestamp: '10:00', + username: 'Alice', + ), + ), + _ExampleCard( + label: 'Hidden — space reserved, avatar invisible', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + leadingVisibility: StreamMessageStyleVisibility.all(StreamVisibility.hidden), + ), + child: const _MessageItem( + avatarIndex: 0, + text: 'Avatar space is preserved for alignment.', + timestamp: '10:01', + ), + ), + ), + _ExampleCard( + label: 'Gone — no space reserved', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + leadingVisibility: StreamMessageStyleVisibility.all(StreamVisibility.gone), + ), + child: const _MessageItem( + avatarIndex: 0, + text: 'Avatar is removed entirely from the layout.', + timestamp: '10:02', + ), + ), + ), + ], + ); + } +} + +class _FullCompositionSection extends StatelessWidget { + const _FullCompositionSection(); + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'FULL COMPOSITION', + description: + 'All slots populated with annotations, reactions, replies, ' + 'and metadata demonstrating the intended layout.', + children: [ + _ExampleCard( + label: 'Incoming — all slots', + child: _MessageItem( + avatarIndex: 0, + text: + 'This message has an annotation, ' + 'reactions, a reply indicator, and full metadata.', + timestamp: '09:41', + username: 'Alice', + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + annotation: StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + reactions: _allReactions.take(4).toList(), + replies: StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ), + ), + const _ExampleCard( + label: 'Outgoing — reactions + status', + child: _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Looks great, merging the PR now!', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + reactions: [ + StreamReactionsItem(emoji: Text('👍'), count: 2), + StreamReactionsItem(emoji: Text('🎉'), count: 1), + ], + ), + ), + ], + ); + } +} + +class _EmojiOnlySection extends StatelessWidget { + const _EmojiOnlySection(); + + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: + 'Messages with 1–3 emojis render without a bubble. ' + 'Shown with the full message widget layout.', + children: [ + const _ExampleCard( + label: 'Single emoji', + child: _MessageItem( + avatarIndex: 0, + text: '👋', + timestamp: '11:00', + username: 'Alice', + isEmojiOnly: true, + ), + ), + const _ExampleCard( + label: 'Multi-emoji with reactions', + child: _MessageItem( + avatarIndex: 2, + text: '🎉👏🔥', + timestamp: '11:01', + username: 'Charlie', + isEmojiOnly: true, + reactions: [ + StreamReactionsItem(emoji: Text('❤'), count: 4), + StreamReactionsItem(emoji: Text('😂'), count: 2), + ], + ), + ), + _ExampleCard( + label: 'Outgoing emoji with replies', + child: _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: '👍🔥', + timestamp: '11:02', + isEmojiOnly: true, + replies: StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, + ), + ), + ), + ], + ); + } +} + +class _ConversationSection extends StatelessWidget { + const _ConversationSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'CONVERSATION', + description: + 'A realistic exchange showing how alignment, stack position, ' + 'and avatar visibility combine in a typical chat thread.', + children: [ + _ExampleCard( + label: 'Mixed thread', + child: Column( + spacing: 2, + children: [ + _MessageItem( + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 0, + text: 'Hey, are you free this weekend?', + ), + _MessageItem( + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 0, + text: 'We could go hiking 🏔️', + timestamp: '09:41', + username: 'Alice', + ), + SizedBox(height: 8), + _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Sounds great! Let me check my schedule.', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + SizedBox(height: 8), + _MessageItem( + avatarIndex: 2, + text: 'Count me in! I know a great trail near the lake 🌲', + timestamp: '09:43', + username: 'Charlie', + ), + SizedBox(height: 8), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 1, + text: 'Perfect, Saturday morning works?', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 1, + text: "I'll bring coffee ☕", + timestamp: '09:44', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + SizedBox(height: 8), + _MessageItem( + avatarIndex: 0, + text: '👍', + timestamp: '09:45', + username: 'Alice', + isEmojiOnly: true, + ), + ], + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + const _ThemeOverrideSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'THEME OVERRIDE', + description: + 'Use StreamMessageItemTheme to override item-level properties ' + 'like background color and avatar size.', + children: [ + _ExampleCard( + label: 'Pinned message with highlight background', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + backgroundColor: colorScheme.backgroundHighlight, + ), + child: const _MessageItem( + avatarIndex: 0, + text: 'This message is pinned to the conversation.', + timestamp: '09:41', + username: 'Alice', + ), + ), + ), + const _ExampleCard( + label: 'Smaller avatar override', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + avatarSize: StreamAvatarSize.sm, + ), + child: _MessageItem( + avatarIndex: 1, + text: 'A message with a smaller avatar.', + timestamp: '09:42', + username: 'Bob', + ), + ), + ), + ], + ); + } +} + +class _MinimalSection extends StatelessWidget { + const _MinimalSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'MINIMAL', + description: 'Stripped-down messages with only essential content.', + children: [ + _ExampleCard( + label: 'Bubble only — no avatar, no footer', + child: _MessageItem( + text: 'Just a bubble, nothing else.', + ), + ), + _ExampleCard( + label: 'Bubble + footer only', + child: _MessageItem( + text: 'Hey!', + timestamp: '09:50', + ), + ), + _ExampleCard( + label: 'Avatar + bubble only', + child: _MessageItem( + avatarIndex: 0, + text: 'Message with avatar, no metadata.', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Sample Data +// ============================================================================= + +const _avatarImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=200', +]; + +const _avatarInitials = ['AJ', 'BK', 'CL', 'DM']; + +const _stackMessages = [ + 'Hey everyone!', + 'Just got back from lunch 🍕', + 'Did you see the latest PR?', + 'Looks great, nice work!', +]; + +const _allReactions = [ + StreamReactionsItem(emoji: Text('👍'), count: 8), + StreamReactionsItem(emoji: Text('❤'), count: 14), + StreamReactionsItem(emoji: Text('😂'), count: 5), + StreamReactionsItem(emoji: Text('🔥'), count: 3), + StreamReactionsItem(emoji: Text('🎉'), count: 2), + StreamReactionsItem(emoji: Text('👏'), count: 7), + StreamReactionsItem(emoji: Text('😮')), + StreamReactionsItem(emoji: Text('🙏'), count: 4), +]; + +List _sampleAvatars(int count, List palette) { + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: _avatarImages[i % _avatarImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_avatarInitials[i % _avatarInitials.length]), + ), + ]; +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _MessageItem extends StatelessWidget { + const _MessageItem({ + this.alignment = StreamMessageAlignment.start, + this.stackPosition = StreamMessageStackPosition.single, + this.channelKind = StreamChannelKind.group, + this.avatarIndex, + required this.text, + this.timestamp, + this.username, + this.edited, + this.status, + this.replies, + this.annotation, + this.reactions, + this.isEmojiOnly = false, + }); + + final StreamMessageAlignment alignment; + final StreamMessageStackPosition stackPosition; + final StreamChannelKind channelKind; + final int? avatarIndex; + final String text; + final String? timestamp; + final String? username; + final Widget? edited; + final Widget? status; + final Widget? replies; + final Widget? annotation; + final List? reactions; + final bool isEmojiOnly; + + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + Widget? leading; + if (avatarIndex case final i?) { + leading = StreamAvatar( + imageUrl: _avatarImages[i % _avatarImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_avatarInitials[i % _avatarInitials.length]), + ); + } + + final messageText = StreamMessageText(text); + final crossAlign = switch (alignment) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + + final Widget messageBody = isEmojiOnly ? messageText : StreamMessageBubble(child: messageText); + + Widget body = Column( + crossAxisAlignment: crossAlign, + mainAxisSize: MainAxisSize.min, + children: [messageBody, ?replies], + ); + + if (reactions case final items?) { + body = StreamReactions.segmented(items: items, overlap: false, child: body); + } + + return StreamMessageWidget( + padding: .zero, + alignment: alignment, + stackPosition: stackPosition, + channelKind: channelKind, + leading: leading, + child: StreamMessageContent( + header: annotation, + footer: timestamp != null + ? StreamMessageMetadata( + timestamp: Text(timestamp!), + username: username != null ? Text(username!) : null, + edited: edited, + status: status, + ) + : null, + child: body, + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + }); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + child, + ], + ), + ); + } +} + +void _showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart index 07fed36..6f9f15d 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart @@ -195,26 +195,22 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamTheme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - return Align( + Widget child = Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isMe ? colorScheme.accentPrimary : colorScheme.backgroundApp, - borderRadius: BorderRadius.circular(16), - border: isMe ? null : Border.all(color: colorScheme.borderSubtle), - ), - child: Text( - message, - style: textTheme.bodyDefault.copyWith( - color: isMe ? colorScheme.textOnAccent : colorScheme.textPrimary, - ), - ), + child: StreamMessageBubble( + child: Text(message), ), ); + + if (isMe) { + child = StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: child, + ); + } + + return child; } } diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index b7d906b..0ae3a3a 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -27,26 +27,39 @@ Widget buildStreamReactionsPlayground(BuildContext context) { labelBuilder: (option) => option.name, description: 'Where reactions sit relative to the bubble.', ); - final alignment = context.knobs.object.dropdown( + final alignmentOption = context.knobs.object.dropdown( label: 'Alignment', - options: StreamReactionsAlignment.values, - initialOption: StreamReactionsAlignment.end, - labelBuilder: (option) => option.name, - description: 'Horizontal alignment of reactions relative to the bubble.', + options: _AlignmentOption.values, + initialOption: _AlignmentOption.defaultValue, + labelBuilder: (option) => option.label, + description: 'Horizontal alignment of reactions. Default derives from message alignment.', + ); + final crossAxisOption = context.knobs.object.dropdown( + label: 'Cross Axis Alignment', + options: _CrossAxisOption.values, + initialOption: _CrossAxisOption.defaultValue, + labelBuilder: (option) => option.label, + description: 'Cross-axis alignment of the column. Default derives from message alignment.', ); final overlap = context.knobs.boolean( label: 'Overlap', initialValue: true, description: 'Reactions overlap the bubble edge with negative spacing.', ); - final indent = context.knobs.double.slider( - label: 'Indent', - initialValue: 8, - min: -8, - max: 8, - divisions: 8, - description: 'Horizontal shift applied to the reaction strip.', + final useIndent = context.knobs.boolean( + label: 'Override Indent', + description: 'Enable to set a custom indent. When off, uses the default (null).', ); + final indent = useIndent + ? context.knobs.double.slider( + label: 'Indent', + initialValue: 8, + min: -8, + max: 8, + divisions: 8, + description: 'Horizontal shift applied to the reaction strip.', + ) + : null; final max = overlap ? context.knobs.int.slider( label: 'Max Visible', @@ -74,15 +87,16 @@ Widget buildStreamReactionsPlayground(BuildContext context) { final items = _allReactionItems.take(reactionCount).toList(); final spacing = context.streamSpacing; - final isOutgoing = direction.isOutgoing; - final crossAxis = isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + final alignment = alignmentOption.value; + final crossAxisAlignment = crossAxisOption.value; Widget buildReaction({required Widget bubble}) => switch (type) { StreamReactionsType.segmented => StreamReactions.segmented( items: items, position: position, alignment: alignment, - crossAxisAlignment: crossAxis, + crossAxisAlignment: crossAxisAlignment, max: max, overlap: overlap, indent: indent, @@ -93,7 +107,7 @@ Widget buildStreamReactionsPlayground(BuildContext context) { items: items, position: position, alignment: alignment, - crossAxisAlignment: crossAxis, + crossAxisAlignment: crossAxisAlignment, max: max, overlap: overlap, indent: indent, @@ -114,17 +128,17 @@ Widget buildStreamReactionsPlayground(BuildContext context) { children: [ _ChatBubble( message: _mediumMessage, - direction: direction, + alignment: direction.alignment, reactionBuilder: buildReaction, ), _ChatBubble( message: _shortMessage, - direction: direction, + alignment: direction.alignment, reactionBuilder: buildReaction, ), _ChatBubble( message: _longMessage, - direction: direction, + alignment: direction.alignment, reactionBuilder: buildReaction, ), ], @@ -138,68 +152,23 @@ Widget buildStreamReactionsPlayground(BuildContext context) { class _ChatBubble extends StatelessWidget { const _ChatBubble({ required this.message, - required this.direction, + required this.alignment, required this.reactionBuilder, }); final String message; - final _MessageDirection direction; + final StreamMessageAlignment alignment; final Widget Function({required Widget bubble}) reactionBuilder; @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; - final spacing = context.streamSpacing; - - final isOutgoing = direction.isOutgoing; - final bubbleColor = isOutgoing ? colorScheme.brand.shade100 : colorScheme.backgroundSurface; - - const bubbleRadius = Radius.circular(20); - final bubbleBorderRadius = isOutgoing - ? const BorderRadius.only( - topLeft: bubbleRadius, - topRight: bubbleRadius, - bottomLeft: bubbleRadius, - ) - : const BorderRadius.only( - topLeft: bubbleRadius, - topRight: bubbleRadius, - bottomRight: bubbleRadius, - ); - - final bubble = Container( - constraints: const BoxConstraints(maxWidth: 280), - padding: EdgeInsets.symmetric( - horizontal: spacing.sm, - vertical: spacing.xs, - ), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: bubbleBorderRadius, - ), - child: Text( - message, - style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), - ), - ); - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - reactionBuilder(bubble: bubble), - SizedBox(height: spacing.xxs), - Padding( - padding: EdgeInsets.symmetric(horizontal: spacing.xxs), - child: Text( - isOutgoing ? '09:41 · Read' : '09:40', - style: textTheme.metadataDefault.copyWith( - color: colorScheme.textTertiary, - ), - ), + return StreamMessagePlacement( + placement: StreamMessagePlacementData(alignment: alignment), + child: StreamMessageContent( + child: reactionBuilder( + bubble: StreamMessageBubble(child: StreamMessageText(message)), ), - ], + ), ); } } @@ -224,8 +193,8 @@ Widget buildStreamReactionsShowcase(BuildContext context) { child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.xl, - children: const [ - _ShowcaseSection( + children: [ + const _ShowcaseSection( title: 'SEGMENTED — FOOTER', description: 'Individual pills per reaction, positioned as a footer ' @@ -233,7 +202,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -241,7 +210,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -251,7 +220,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -263,7 +232,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'SEGMENTED — HEADER', description: 'Individual pills as a header. Reactions paint on top ' @@ -271,7 +240,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.header, items: [ @@ -282,7 +251,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.header, items: _allReactionItems, @@ -290,7 +259,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'CLUSTERED', description: 'All reactions grouped into a single chip. Shown in both ' @@ -298,14 +267,14 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _longMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: _allReactionItems, ), _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.clustered, position: StreamReactionsPosition.header, items: [ @@ -316,7 +285,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: [ @@ -326,7 +295,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'OVERFLOW', description: 'When reactions exceed the max visible limit, extras are ' @@ -334,7 +303,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -342,7 +311,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -350,7 +319,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'COUNT RULES', description: 'If any reaction has count > 1, all chips show counts. ' @@ -358,21 +327,21 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: 'Single emoji, count 1 — no count shown.', - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [StreamReactionsItem(emoji: Text('👍'))], ), _ThreadMessage( message: 'Single emoji, count 5 — count shown.', - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [StreamReactionsItem(emoji: Text('❤'), count: 5)], ), _ThreadMessage( message: 'Multiple emojis, all count 1 — no counts.', - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -383,7 +352,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: 'Mixed counts — all show counts.', - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -395,7 +364,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: 'Clustered — total count shown when > 1.', - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: [ @@ -406,7 +375,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'DETACHED', description: 'Reactions with overlap disabled — separated from the bubble ' @@ -414,7 +383,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -426,7 +395,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: [ @@ -437,7 +406,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -445,6 +414,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), + _EmojiOnlyShowcaseSection(), ], ), ), @@ -460,7 +430,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { class _ThreadMessage { const _ThreadMessage({ required this.message, - required this.direction, + required this.alignment, required this.type, required this.position, required this.items, @@ -469,7 +439,7 @@ class _ThreadMessage { }); final String message; - final _MessageDirection direction; + final StreamMessageAlignment alignment; final StreamReactionsType type; final StreamReactionsPosition position; final List items; @@ -538,36 +508,24 @@ class _ShowcaseSection extends StatelessWidget { for (final t in threads) _ChatBubble( message: t.message, - direction: t.direction, - reactionBuilder: ({required bubble}) { - final isOut = t.direction.isOutgoing; - final reactionAlignment = t.overlap - ? (isOut ? StreamReactionsAlignment.start : StreamReactionsAlignment.end) - : (isOut ? StreamReactionsAlignment.end : StreamReactionsAlignment.start); - final crossAxis = isOut ? CrossAxisAlignment.end : CrossAxisAlignment.start; - - return switch (t.type) { - StreamReactionsType.segmented => StreamReactions.segmented( - items: t.items, - position: t.position, - alignment: reactionAlignment, - crossAxisAlignment: crossAxis, - max: t.max, - overlap: t.overlap, - child: bubble, - onPressed: () {}, - ), - StreamReactionsType.clustered => StreamReactions.clustered( - items: t.items, - position: t.position, - alignment: reactionAlignment, - crossAxisAlignment: crossAxis, - max: t.max, - overlap: t.overlap, - child: bubble, - onPressed: () {}, - ), - }; + alignment: t.alignment, + reactionBuilder: ({required bubble}) => switch (t.type) { + StreamReactionsType.segmented => StreamReactions.segmented( + items: t.items, + position: t.position, + max: t.max, + overlap: t.overlap, + child: bubble, + onPressed: () {}, + ), + StreamReactionsType.clustered => StreamReactions.clustered( + items: t.items, + position: t.position, + max: t.max, + overlap: t.overlap, + child: bubble, + onPressed: () {}, + ), }, ), ], @@ -581,6 +539,160 @@ class _ShowcaseSection extends StatelessWidget { } } +class _EmojiOnlyShowcaseSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final palette = colorScheme.avatarPalette; + + const reactions = [ + StreamReactionsItem(emoji: Text('❤'), count: 4), + StreamReactionsItem(emoji: Text('😂'), count: 2), + ]; + + const singleReaction = [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + ]; + + Widget emojiMessage({ + required String text, + required StreamMessageAlignment alignment, + required List items, + bool showReplies = false, + StreamReactionsPosition position = StreamReactionsPosition.footer, + }) { + final isEnd = alignment == StreamMessageAlignment.end; + final crossAxis = isEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + final messageText = StreamMessageText(text); + + Widget body = StreamReactions.segmented( + items: items, + position: position, + overlap: position == StreamReactionsPosition.header, + alignment: position == StreamReactionsPosition.header + ? StreamReactionsAlignment.end + : StreamReactionsAlignment.start, + indent: position == StreamReactionsPosition.header ? 8 : null, + onPressed: () {}, + child: messageText, + ); + + if (showReplies) { + body = Column( + crossAxisAlignment: crossAxis, + mainAxisSize: MainAxisSize.min, + children: [ + body, + StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + alignment: alignment, + showConnector: false, + onTap: () {}, + ), + ], + ); + } + + return StreamMessagePlacement( + placement: StreamMessagePlacementData(alignment: alignment), + child: Align( + alignment: isEnd ? Alignment.centerRight : Alignment.centerLeft, + child: StreamMessageContent(child: body), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'EMOJI-ONLY MESSAGES'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.lg), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(spacing.md, spacing.sm, spacing.md, spacing.xs), + child: Text( + 'Emoji-only messages (1–3 emojis) render without a bubble. ' + 'Shown with reactions and replies.', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + Padding( + padding: EdgeInsets.all(spacing.md), + child: Column( + spacing: spacing.lg, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + emojiMessage( + text: '👋', + alignment: StreamMessageAlignment.start, + items: reactions, + ), + emojiMessage( + text: '❤️🔥', + alignment: StreamMessageAlignment.end, + items: singleReaction, + position: StreamReactionsPosition.header, + ), + emojiMessage( + text: '😂', + alignment: StreamMessageAlignment.start, + items: reactions, + showReplies: true, + ), + emojiMessage( + text: '🎉👏🔥', + alignment: StreamMessageAlignment.end, + items: reactions, + showReplies: true, + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +List _sampleAvatars(int count, List palette) { + const images = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + ]; + const initials = ['AB', 'CD', 'EF']; + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: images[i % images.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(initials[i % initials.length]), + ), + ]; +} + class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.label}); @@ -630,11 +742,39 @@ void _showSnack(BuildContext context, String message) { // ============================================================================= enum _MessageDirection { - incoming, - outgoing + incoming(StreamMessageAlignment.start), + outgoing(StreamMessageAlignment.end), ; - bool get isOutgoing => this == outgoing; + const _MessageDirection(this.alignment); + + final StreamMessageAlignment alignment; +} + +enum _AlignmentOption { + defaultValue('Default', null), + start('start', StreamReactionsAlignment.start), + end('end', StreamReactionsAlignment.end), + ; + + const _AlignmentOption(this.label, this.value); + + final String label; + final StreamReactionsAlignment? value; +} + +enum _CrossAxisOption { + defaultValue('Default', null), + start('start', CrossAxisAlignment.start), + center('center', CrossAxisAlignment.center), + end('end', CrossAxisAlignment.end), + stretch('stretch', CrossAxisAlignment.stretch), + ; + + const _CrossAxisOption(this.label, this.value); + + final String label; + final CrossAxisAlignment? value; } // ============================================================================= diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index 249fc69..80dc0c0 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -53,6 +53,7 @@ class ThemeConfiguration extends ChangeNotifier { Color? _backgroundSurfaceStrong; Color? _backgroundOverlay; Color? _backgroundDisabled; + Color? _backgroundHighlight; // ========================================================================= // Border Colors - Core @@ -129,6 +130,7 @@ class ThemeConfiguration extends ChangeNotifier { Color get backgroundSurfaceStrong => _backgroundSurfaceStrong ?? _themeData.colorScheme.backgroundSurfaceStrong; Color get backgroundOverlay => _backgroundOverlay ?? _themeData.colorScheme.backgroundOverlay; Color get backgroundDisabled => _backgroundDisabled ?? _themeData.colorScheme.backgroundDisabled; + Color get backgroundHighlight => _backgroundHighlight ?? _themeData.colorScheme.backgroundHighlight; // ========================================================================= // Getters - Border Core @@ -210,6 +212,7 @@ class ThemeConfiguration extends ChangeNotifier { void setBackgroundSurfaceStrong(Color color) => _update(() => _backgroundSurfaceStrong = color); void setBackgroundOverlay(Color color) => _update(() => _backgroundOverlay = color); void setBackgroundDisabled(Color color) => _update(() => _backgroundDisabled = color); + void setBackgroundHighlight(Color color) => _update(() => _backgroundHighlight = color); // Border Core void setBorderDefault(Color color) => _update(() => _borderDefault = color); @@ -296,6 +299,7 @@ class ThemeConfiguration extends ChangeNotifier { _backgroundSurfaceStrong = null; _backgroundOverlay = null; _backgroundDisabled = null; + _backgroundHighlight = null; // Border Core _borderDefault = null; _borderSubtle = null; @@ -379,6 +383,7 @@ class ThemeConfiguration extends ChangeNotifier { backgroundSurfaceStrong: _backgroundSurfaceStrong, backgroundOverlay: _backgroundOverlay, backgroundDisabled: _backgroundDisabled, + backgroundHighlight: _backgroundHighlight, // Border Core borderDefault: _borderDefault, borderSubtle: _borderSubtle, diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart index 942be4a..0f55aae 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -348,6 +348,11 @@ class _ThemeCustomizationPanelState extends State { color: config.backgroundDisabled, onColorChanged: config.setBackgroundDisabled, ), + ColorPickerTile( + label: 'backgroundHighlight', + color: config.backgroundHighlight, + onColorChanged: config.setBackgroundHighlight, + ), ], ), ); diff --git a/apps/design_system_gallery/pubspec.yaml b/apps/design_system_gallery/pubspec.yaml index f816ad5..c4263bb 100644 --- a/apps/design_system_gallery/pubspec.yaml +++ b/apps/design_system_gallery/pubspec.yaml @@ -12,7 +12,9 @@ dependencies: flutter: sdk: flutter flutter_colorpicker: ^1.1.0 + flutter_markdown_plus: ^1.0.7 marionette_flutter: ^0.3.0 + markdown: ^7.3.0 provider: ^6.1.5+1 stream_core_flutter: path: ../../packages/stream_core_flutter diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 07ad15c..47b05f6 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -13,6 +13,7 @@ export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButt export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; export 'components/common/stream_flex.dart'; export 'components/common/stream_progress_bar.dart' hide DefaultStreamProgressBar; +export 'components/common/stream_visibility.dart'; 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; @@ -22,7 +23,18 @@ export 'components/emoji/data/stream_emoji_data.dart'; export 'components/emoji/data/stream_supported_emojis.dart'; export 'components/emoji/stream_emoji_picker_sheet.dart'; export 'components/list/stream_list_tile.dart' hide DefaultStreamListTile; +export 'components/message/stream_message_annotation.dart' hide DefaultStreamMessageAnnotation; +export 'components/message/stream_message_bubble.dart' hide DefaultStreamMessageBubble; +export 'components/message/stream_message_content.dart' hide DefaultStreamMessageContent; +export 'components/message/stream_message_metadata.dart' hide DefaultStreamMessageMetadata; +export 'components/message/stream_message_replies.dart' hide DefaultStreamMessageReplies; +export 'components/message/stream_message_text.dart' hide DefaultStreamMessageText; +export 'components/message/stream_message_widget.dart' hide DefaultStreamMessageWidget; export 'components/message_composer.dart'; +export 'components/message_placement/stream_channel_kind.dart'; +export 'components/message_placement/stream_message_alignment.dart'; +export 'components/message_placement/stream_message_placement.dart'; +export 'components/message_placement/stream_message_stack_position.dart'; export 'components/reaction/stream_reactions.dart' hide DefaultStreamReactions; export 'factory/stream_component_factory.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart index facc65b..a2c7850 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_avatar_theme.dart'; import '../../theme/components/stream_badge_count_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; import '../badge/stream_badge_count.dart'; import '../common/stream_flex.dart'; @@ -180,6 +181,8 @@ class DefaultStreamAvatarStack extends StatelessWidget { Widget build(BuildContext context) { if (props.children.isEmpty) return const SizedBox.shrink(); + final colorScheme = context.streamColorScheme; + final effectiveSize = props.size ?? StreamAvatarStackSize.sm; final avatarSize = _avatarSizeForStackSize(effectiveSize); final extraBadgeSize = _badgeCountSizeForStackSize(effectiveSize); @@ -189,9 +192,18 @@ class DefaultStreamAvatarStack extends StatelessWidget { final visible = props.children.take(props.max).toList(); final extraCount = props.children.length - visible.length; + const avatarBorderWidth = 2.0; + return MediaQuery.withNoTextScaling( child: StreamAvatarTheme( - data: StreamAvatarThemeData(size: avatarSize), + data: StreamAvatarThemeData( + size: avatarSize, + border: Border.all( + width: avatarBorderWidth, + color: colorScheme.borderOnDark, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), child: StreamRow( spacing: -diameter * props.overlap, mainAxisSize: MainAxisSize.min, diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart b/packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart new file mode 100644 index 0000000..7d255f8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart @@ -0,0 +1,35 @@ +/// Controls the visibility and layout participation of a widget slot. +/// +/// This is typically used for optional slots (such as leading avatars in a +/// message row) where a theme needs to express whether the widget should +/// be shown, hidden while still reserving space, or removed entirely. +/// +/// {@tool snippet} +/// +/// Conditionally hide the avatar in non-bottom stack positions: +/// +/// ```dart +/// StreamMessageItemTheme( +/// data: StreamMessageItemThemeData( +/// leadingVisibility: StreamMessageStyleProperty.resolveWith( +/// (p) => switch (p.stackPosition) { +/// StreamMessageStackPosition.bottom || +/// StreamMessageStackPosition.single => StreamVisibility.visible, +/// _ => StreamVisibility.hidden, +/// }, +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +enum StreamVisibility { + /// The widget is fully visible and participates in layout. + visible, + + /// The widget is invisible but still occupies its layout space. + hidden, + + /// The widget is removed from the layout entirely — it takes no space. + gone, +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart new file mode 100644 index 0000000..274d04f --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// An annotation row for displaying contextual message annotations. +/// +/// Displays an optional [leading] widget (typically an icon) and a [label] +/// widget in a horizontal row with themed styling. Can be used for various +/// annotation types such as "Saved", "Pinned", "Reminder", etc. +/// +/// All content is provided by the caller via widget slots. The provided +/// widgets are automatically styled according to +/// [StreamMessageAnnotationStyle]. +/// +/// The visual order is always `[leading, label]` with configurable spacing +/// between them. When [leading] is null, only the label is shown. +/// +/// {@tool snippet} +/// +/// Basic annotation with icon and label: +/// +/// ```dart +/// StreamMessageAnnotation( +/// leading: Icon(StreamIcons.bookmark), +/// label: Text('Saved'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Tappable annotation: +/// +/// ```dart +/// StreamMessageAnnotation( +/// leading: Icon(StreamIcons.pin), +/// label: Text('Pinned'), +/// onTap: () => print('Annotation tapped'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAnnotationStyle], for customizing annotation appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. +class StreamMessageAnnotation extends StatelessWidget { + /// Creates a message annotation row. + /// + /// The [label] is required; [leading] is optional and omitted from the row + /// when null. + StreamMessageAnnotation({ + super.key, + Widget? leading, + required Widget label, + VoidCallback? onTap, + VoidCallback? onLongPress, + StreamMessageAnnotationStyle? style, + }) : props = .new( + leading: leading, + label: label, + onTap: onTap, + onLongPress: onLongPress, + style: style, + ); + + /// The properties that configure this annotation row. + final StreamMessageAnnotationProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageAnnotation; + if (builder != null) return builder(context, props); + return DefaultStreamMessageAnnotation(props: props); + } +} + +/// Properties for configuring a [StreamMessageAnnotation]. +/// +/// See also: +/// +/// * [StreamMessageAnnotation], which uses these properties. +class StreamMessageAnnotationProps { + /// Creates properties for a message annotation row. + const StreamMessageAnnotationProps({ + this.leading, + required this.label, + this.onTap, + this.onLongPress, + this.style, + }); + + /// The leading widget, typically an [Icon]. + /// + /// When null, the row displays only the [label]. + /// + /// Styled by [StreamMessageAnnotationStyle.iconColor] and + /// [StreamMessageAnnotationStyle.iconSize]. + final Widget? leading; + + /// The label widget, typically a [Text] showing the annotation type. + /// + /// Styled by [StreamMessageAnnotationStyle.textStyle] and + /// [StreamMessageAnnotationStyle.textColor]. + final Widget label; + + /// Called when the annotation row is tapped. + final VoidCallback? onTap; + + /// Called when the annotation row is long-pressed. + final VoidCallback? onLongPress; + + /// Optional style overrides for placement-aware styling. + /// + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageAnnotationStyle? style; +} + +/// The default implementation of [StreamMessageAnnotation]. +/// +/// See also: +/// +/// * [StreamMessageAnnotation], the public API widget. +/// * [StreamMessageAnnotationProps], which configures this widget. +class DefaultStreamMessageAnnotation extends StatelessWidget { + /// Creates a default message annotation row with the given [props]. + const DefaultStreamMessageAnnotation({super.key, required this.props}); + + /// The properties that configure this annotation row. + final StreamMessageAnnotationProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final annotationStyle = props.style ?? StreamMessageItemTheme.of(context).annotation; + final defaults = _StreamMessageAnnotationDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [annotationStyle, defaults]); + + final effectiveTextStyle = resolve((s) => s?.textStyle); + final effectiveTextColor = resolve((s) => s?.textColor); + final effectiveSpacing = resolve((s) => s?.spacing); + final effectivePadding = resolve((s) => s?.padding); + + Widget? leadingWidget; + if (props.leading case final leading?) { + final effectiveIconColor = resolve((s) => s?.iconColor); + final effectiveIconSize = resolve((s) => s?.iconSize); + + leadingWidget = IconTheme.merge( + data: IconThemeData(color: effectiveIconColor, size: effectiveIconSize), + child: leading, + ); + } + + final labelWidget = Flexible( + child: AnimatedDefaultTextStyle( + style: effectiveTextStyle.copyWith(color: effectiveTextColor), + duration: kThemeChangeDuration, + child: props.label, + ), + ); + + final child = Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: effectiveSpacing, + children: [?leadingWidget, labelWidget], + ), + ); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: props.onTap, + onLongPress: props.onLongPress, + child: child, + ); + } +} + +class _StreamMessageAnnotationDefaults extends StreamMessageAnnotationStyle { + _StreamMessageAnnotationDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + StreamMessageStyleProperty get textStyle => .all(_textTheme.metadataEmphasis); + + @override + StreamMessageStyleProperty get textColor => .all(_colorScheme.textPrimary); + + @override + StreamMessageStyleProperty get iconColor => .all(_colorScheme.textPrimary); + + @override + StreamMessageStyleProperty get iconSize => .all(16); + + @override + StreamMessageStyleProperty get spacing => .all(_spacing.xxs); + + @override + StreamMessageStyleProperty get padding => .all(.symmetric(vertical: _spacing.xxs)); +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart new file mode 100644 index 0000000..f89e192 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart @@ -0,0 +1,183 @@ +import 'package:flutter/widgets.dart'; + +import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// A styled container that wraps message content with a themed background, +/// shape, and padding. +/// +/// [StreamMessageBubble] is the visual shell of a chat message. Metadata, +/// reactions, and reply indicators compose around it at a higher level. +/// +/// If a [StreamMessagePlacement] is found in the ancestor tree, style +/// properties automatically adapt to the current message placement. +/// +/// {@tool snippet} +/// +/// A simple text bubble using theme defaults: +/// +/// ```dart +/// StreamMessageBubble(child: Text('Hello, world!')) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// A bubble with uniform style overrides: +/// +/// ```dart +/// StreamMessageBubble( +/// style: StreamMessageBubbleStyle.from( +/// backgroundColor: Colors.white, +/// padding: EdgeInsets.all(16), +/// ), +/// child: Text('Styled bubble'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageBubbleStyle], for resolver-based styling. +/// * [StreamMessageItemTheme], for theming via the widget tree. +/// * [StreamMessagePlacement], which provides the placement context +/// used to resolve styles. +class StreamMessageBubble extends StatelessWidget { + /// Creates a message bubble. + /// + /// The [child] is required. An optional [style] can override individual + /// fields; unset fields fall back to theme, then to defaults. + StreamMessageBubble({ + super.key, + required Widget child, + StreamMessageBubbleStyle? style, + }) : props = .new(child: child, style: style); + + /// The properties that configure this bubble. + final StreamMessageBubbleProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageBubble; + if (builder != null) return builder(context, props); + return DefaultStreamMessageBubble(props: props); + } +} + +/// Properties for configuring a [StreamMessageBubble]. +/// +/// See also: +/// +/// * [StreamMessageBubble], which uses these properties. +class StreamMessageBubbleProps { + /// Creates properties for a message bubble. + const StreamMessageBubbleProps({ + required this.child, + this.style, + }); + + /// The content widget displayed inside the bubble. + final Widget child; + + /// Optional style overrides for placement-aware styling. + /// + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageBubbleStyle? style; +} + +/// The default implementation of [StreamMessageBubble]. +/// +/// See also: +/// +/// * [StreamMessageBubble], the public API widget. +/// * [StreamMessageBubbleProps], which configures this widget. +class DefaultStreamMessageBubble extends StatelessWidget { + /// Creates a default message bubble with the given [props]. + const DefaultStreamMessageBubble({super.key, required this.props}); + + /// The properties that configure this bubble. + final StreamMessageBubbleProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final bubbleStyle = props.style ?? StreamMessageItemTheme.of(context).bubble; + final defaults = _StreamMessageBubbleDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [bubbleStyle, defaults]); + + final effectiveSide = resolve((s) => s?.side); + final effectiveShape = resolve((s) => s?.shape).copyWith(side: effectiveSide); + final effectivePadding = resolve((s) => s?.padding); + final effectiveConstraints = resolve((s) => s?.constraints); + final effectiveBackgroundColor = resolve((s) => s?.backgroundColor); + + return ConstrainedBox( + constraints: effectiveConstraints, + child: DecoratedBox( + decoration: ShapeDecoration( + shape: effectiveShape, + color: effectiveBackgroundColor, + ), + child: Padding( + padding: effectivePadding, + child: props.child, + ), + ), + ); + } +} + +class _StreamMessageBubbleDefaults extends StreamMessageBubbleStyle { + _StreamMessageBubbleDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamRadius _radius = _context.streamRadius; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + StreamMessageStyleProperty get backgroundColor => .resolveWith( + (placement) => switch (placement.alignment) { + .start => _colorScheme.backgroundSurface, + .end => _colorScheme.brand.shade100, + }, + ); + + @override + StreamMessageStyleProperty get shape => .resolveWith( + (placement) => RoundedSuperellipseBorder( + borderRadius: switch ((placement.alignment, placement.stackPosition)) { + (.start, .single || .bottom) => BorderRadiusDirectional.only( + topStart: _radius.xxl, + topEnd: _radius.xxl, + bottomEnd: _radius.xxl, + ), + (.end, .single || .bottom) => BorderRadiusDirectional.only( + topStart: _radius.xxl, + topEnd: _radius.xxl, + bottomStart: _radius.xxl, + ), + _ => BorderRadiusDirectional.all(_radius.xxl), + }, + ), + ); + + @override + StreamMessageStyleBorderSide get side => .resolveWith( + (placement) => switch (placement.alignment) { + .start => BorderSide(color: _colorScheme.borderSubtle), + .end => BorderSide(color: _colorScheme.brand.shade100), + }, + ); + + @override + StreamMessageStyleProperty get padding => .all( + .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs), + ); + + @override + StreamMessageStyleProperty get constraints => .all(const BoxConstraints(minHeight: 20)); +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart new file mode 100644 index 0000000..784f1ed --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart @@ -0,0 +1,169 @@ +import 'package:flutter/widgets.dart'; + +import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// A composite layout container that arranges message primitives into the +/// full message content structure. +/// +/// [StreamMessageContent] composes three vertical sections — [header], +/// [child], and [footer] — stacked in a [Column]. It does not render any +/// visual decoration itself; each section is an opaque widget slot that +/// the caller fills with pre-composed primitives. +/// +/// The typical composition is: +/// +/// * **[header]** — annotation rows (pinned, saved, reminder, etc.) +/// * **[child]** — the message bubble and reply indicator, optionally +/// wrapped with reactions +/// * **[footer]** — metadata (timestamp, delivery status, etc.) +/// +/// {@tool snippet} +/// +/// Incoming message with annotations, reactions, replies, and metadata: +/// +/// ```dart +/// StreamMessageContent( +/// header: Column( +/// crossAxisAlignment: CrossAxisAlignment.start, +/// children: [ +/// StreamMessageAnnotation( +/// leading: Icon(StreamIcons.pin), +/// label: Text('Pinned'), +/// ), +/// ], +/// ), +/// footer: StreamMessageMetadata(timestamp: Text('09:41')), +/// child: StreamReactions.clustered( +/// items: [StreamReactionsItem(emoji: Text('😂'))], +/// child: Column( +/// crossAxisAlignment: CrossAxisAlignment.start, +/// children: [ +/// StreamMessageBubble(child: Text('Hello, world!')), +/// StreamMessageReplies(label: Text('3 replies')), +/// ], +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Minimal message with just a bubble: +/// +/// ```dart +/// StreamMessageContent( +/// child: StreamMessageBubble(child: Text('Hey!')), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageBubble], the visual shell of a message. +/// * [StreamMessageAnnotation], for annotation rows. +/// * [StreamMessageReplies], for thread reply indicators. +/// * [StreamMessageMetadata], for timestamp and delivery status. +/// * [StreamReactions], for wrapping content with reaction chips. +class StreamMessageContent extends StatelessWidget { + /// Creates a message content layout. + /// + /// The [child] is required; [header] and [footer] are optional and + /// omitted from the layout when null. + StreamMessageContent({ + super.key, + Widget? header, + required Widget child, + Widget? footer, + double? spacing, + }) : props = .new( + header: header, + child: child, + footer: footer, + spacing: spacing, + ); + + /// The properties that configure this content layout. + final StreamMessageContentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageContent; + if (builder != null) return builder(context, props); + return DefaultStreamMessageContent(props: props); + } +} + +/// Properties for configuring a [StreamMessageContent]. +/// +/// See also: +/// +/// * [StreamMessageContent], which uses these properties. +class StreamMessageContentProps { + /// Creates properties for a message content layout. + const StreamMessageContentProps({ + this.header, + required this.child, + this.footer, + this.spacing, + }); + + /// Content displayed above the body. + /// + /// Typically a column of [StreamMessageAnnotation] widgets showing + /// pinned, saved, reminder, or other message annotations. + /// + /// When null, no header is shown. + final Widget? header; + + /// The body content of the message. + /// + /// Typically a [StreamMessageBubble] and [StreamMessageReplies] composed + /// in a [Column] and optionally wrapped with [StreamReactions]. + final Widget child; + + /// Content displayed below the body. + /// + /// Typically a [StreamMessageMetadata] widget showing timestamp and + /// delivery status. + /// + /// When null, no footer is shown. + final Widget? footer; + + /// The vertical spacing between the header, child, and footer sections. + /// + /// If null, defaults to [StreamSpacing.xxs]. + final double? spacing; +} + +/// The default implementation of [StreamMessageContent]. +/// +/// See also: +/// +/// * [StreamMessageContent], the public API widget. +/// * [StreamMessageContentProps], which configures this widget. +class DefaultStreamMessageContent extends StatelessWidget { + /// Creates a default message content layout with the given [props]. + const DefaultStreamMessageContent({super.key, required this.props}); + + /// The properties that configure this content layout. + final StreamMessageContentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final effectiveSpacing = props.spacing ?? spacing.xxs; + final crossAxisAlignment = StreamMessagePlacement.crossAxisAlignmentOf(context); + + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: crossAxisAlignment, + children: [?props.header, props.child, ?props.footer], + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart new file mode 100644 index 0000000..a47f5e8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart @@ -0,0 +1,252 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// The bottom metadata row of a chat message bubble. +/// +/// Displays a [timestamp], and optional [status] icon, [username], and +/// [edited] indicator in a horizontal row with themed styling. +/// +/// All content is provided by the caller via widget slots. The provided +/// widgets are automatically styled according to +/// [StreamMessageMetadataStyle]. +/// +/// {@tool snippet} +/// +/// Incoming message (no delivery status): +/// +/// ```dart +/// StreamMessageMetadata( +/// timestamp: Text('09:41'), +/// username: Text('Alice'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Outgoing message with delivery status and edited indicator: +/// +/// ```dart +/// StreamMessageMetadata( +/// timestamp: Text('09:41'), +/// status: Icon(StreamIcons.doupleCheckmark1Small), +/// edited: Text('Edited'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageMetadataStyle], for customizing metadata appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. +class StreamMessageMetadata extends StatelessWidget { + /// Creates a message metadata row. + /// + /// The [timestamp] is required; all other slots are optional and omitted + /// from the row when null. + StreamMessageMetadata({ + super.key, + required Widget timestamp, + Widget? status, + Widget? username, + Widget? edited, + StreamMessageMetadataStyle? style, + }) : props = .new( + timestamp: timestamp, + status: status, + username: username, + edited: edited, + style: style, + ); + + /// The properties that configure this metadata row. + final StreamMessageMetadataProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageMetadata; + if (builder != null) return builder(context, props); + return DefaultStreamMessageMetadata(props: props); + } +} + +/// Properties for configuring a [StreamMessageMetadata]. +/// +/// See also: +/// +/// * [StreamMessageMetadata], which uses these properties. +class StreamMessageMetadataProps { + /// Creates properties for a message metadata row. + const StreamMessageMetadataProps({ + required this.timestamp, + this.status, + this.username, + this.edited, + this.style, + }); + + /// The timestamp widget, typically a [Text] displaying the message time. + /// + /// Styled by [StreamMessageMetadataStyle.timestampTextStyle] and + /// [StreamMessageMetadataStyle.timestampColor]. + final Widget timestamp; + + /// An optional status icon widget indicating delivery state. + /// + /// Typically an [Icon] such as a clock (sending), single checkmark (sent), + /// or double checkmark (delivered/read). + /// + /// Styled by [StreamMessageMetadataStyle.statusColor] and + /// [StreamMessageMetadataStyle.statusIconSize]. + final Widget? status; + + /// An optional username widget displaying the sender name. + /// + /// Styled by [StreamMessageMetadataStyle.usernameTextStyle] and + /// [StreamMessageMetadataStyle.usernameColor]. + final Widget? username; + + /// An optional edited indicator widget. + /// + /// Styled by [StreamMessageMetadataStyle.editedTextStyle] and + /// [StreamMessageMetadataStyle.editedColor]. + final Widget? edited; + + /// Optional style overrides for placement-aware styling. + /// + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageMetadataStyle? style; +} + +/// The default implementation of [StreamMessageMetadata]. +/// +/// See also: +/// +/// * [StreamMessageMetadata], the public API widget. +/// * [StreamMessageMetadataProps], which configures this widget. +class DefaultStreamMessageMetadata extends StatelessWidget { + /// Creates a default message metadata row with the given [props]. + const DefaultStreamMessageMetadata({super.key, required this.props}); + + /// The properties that configure this metadata row. + final StreamMessageMetadataProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final metadataStyle = props.style ?? StreamMessageItemTheme.of(context).metadata; + final defaults = _StreamMessageMetadataDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [metadataStyle, defaults]); + + final effectiveUsernameTextStyle = resolve((s) => s?.usernameTextStyle); + final effectiveUsernameColor = resolve((s) => s?.usernameColor); + final effectiveTimestampTextStyle = resolve((s) => s?.timestampTextStyle); + final effectiveTimestampColor = resolve((s) => s?.timestampColor); + final effectiveEditedTextStyle = resolve((s) => s?.editedTextStyle); + final effectiveEditedColor = resolve((s) => s?.editedColor); + final effectiveStatusColor = resolve((s) => s?.statusColor); + final effectiveStatusIconSize = resolve((s) => s?.statusIconSize); + final effectiveSpacing = resolve((s) => s?.spacing); + final effectiveStatusSpacing = resolve((s) => s?.statusSpacing); + final effectiveMinHeight = resolve((s) => s?.minHeight); + + Widget? usernameWidget; + if (props.username case final username?) { + usernameWidget = Flexible( + child: AnimatedDefaultTextStyle( + style: effectiveUsernameTextStyle.copyWith(color: effectiveUsernameColor), + duration: kThemeChangeDuration, + child: username, + ), + ); + } + + final timestampWidget = AnimatedDefaultTextStyle( + style: effectiveTimestampTextStyle.copyWith(color: effectiveTimestampColor), + duration: kThemeChangeDuration, + child: props.timestamp, + ); + + Widget? statusWidget; + if (props.status case final status?) { + statusWidget = IconTheme.merge( + data: IconThemeData( + color: effectiveStatusColor, + size: effectiveStatusIconSize, + ), + child: status, + ); + } + + final statusTimestampWidget = Row( + mainAxisSize: MainAxisSize.min, + spacing: effectiveStatusSpacing, + children: [?statusWidget, timestampWidget], + ); + + Widget? editedWidget; + if (props.edited case final edited?) { + editedWidget = AnimatedDefaultTextStyle( + style: effectiveEditedTextStyle.copyWith(color: effectiveEditedColor), + duration: kThemeChangeDuration, + child: edited, + ); + } + + return ConstrainedBox( + constraints: .new(minHeight: effectiveMinHeight), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: effectiveSpacing, + children: [?usernameWidget, statusTimestampWidget, ?editedWidget], + ), + ); + } +} + +class _StreamMessageMetadataDefaults extends StreamMessageMetadataStyle { + _StreamMessageMetadataDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + StreamMessageStyleProperty get usernameTextStyle => .all(_textTheme.metadataEmphasis); + + @override + StreamMessageStyleProperty get usernameColor => .all(_colorScheme.textSecondary); + + @override + StreamMessageStyleProperty get timestampTextStyle => .all(_textTheme.metadataDefault); + + @override + StreamMessageStyleProperty get timestampColor => .all(_colorScheme.textTertiary); + + @override + StreamMessageStyleProperty get editedTextStyle => .all(_textTheme.metadataDefault); + + @override + StreamMessageStyleProperty get editedColor => .all(_colorScheme.textTertiary); + + @override + StreamMessageStyleProperty get statusColor => .all(_colorScheme.textTertiary); + + @override + StreamMessageStyleProperty get statusIconSize => .all(16); + + @override + StreamMessageStyleProperty get spacing => .all(_spacing.xs); + + @override + StreamMessageStyleProperty get statusSpacing => .all(_spacing.xxs); + + @override + StreamMessageStyleProperty get minHeight => .all(24); +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart new file mode 100644 index 0000000..645bad0 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart @@ -0,0 +1,388 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; +import '../avatar/stream_avatar_stack.dart'; +import '../message_placement/stream_message_alignment.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// A tappable row showing reply count, participant avatars, and an optional +/// connector for threaded messages. +/// +/// Displays an optional [label] (typically a reply count), a list of +/// participant [avatars], and an optional connector linking the row to the +/// parent message bubble. +/// +/// The visual order of elements is controlled by [alignment]: +/// * [StreamMessageAlignment.start]: `[connector, avatars, label]` +/// * [StreamMessageAlignment.end]: `[label, avatars, connector]` +/// +/// When [showConnector] is true, a connector is displayed that visually +/// links the row to the message bubble above. The connector adapts to +/// [alignment] and the ambient [TextDirection]. +/// +/// RTL support comes from the ambient [TextDirection] automatically — the +/// alignment only controls the semantic order, not the physical direction. +/// +/// {@tool snippet} +/// +/// Incoming message with replies: +/// +/// ```dart +/// StreamMessageReplies( +/// label: Text('3 replies'), +/// avatars: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// ], +/// showConnector: true, +/// onTap: () => openThread(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Outgoing message with replies (end alignment): +/// +/// ```dart +/// StreamMessageReplies( +/// label: Text('5 replies'), +/// avatars: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// ], +/// showConnector: true, +/// alignment: StreamMessageAlignment.end, +/// onTap: () => openThread(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageRepliesStyle], for customizing replies appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. +/// * [StreamMessageAlignment], for controlling element order. +/// * [StreamAvatarStack], which renders the avatars internally. +class StreamMessageReplies extends StatelessWidget { + /// Creates a message replies row. + /// + /// All slots are optional — when null or empty, they are omitted from the + /// row. + StreamMessageReplies({ + super.key, + Widget? label, + Iterable? avatars, + StreamAvatarStackSize avatarSize = .sm, + int maxAvatars = 3, + bool showConnector = true, + VoidCallback? onTap, + VoidCallback? onLongPress, + StreamMessageAlignment? alignment, + Clip? clipBehavior, + StreamMessageRepliesStyle? style, + }) : props = .new( + label: label, + avatars: avatars, + avatarSize: avatarSize, + maxAvatars: maxAvatars, + showConnector: showConnector, + onTap: onTap, + onLongPress: onLongPress, + alignment: alignment, + clipBehavior: clipBehavior, + style: style, + ); + + /// The properties that configure this replies row. + final StreamMessageRepliesProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageReplies; + if (builder != null) return builder(context, props); + return DefaultStreamMessageReplies(props: props); + } +} + +/// Properties for configuring a [StreamMessageReplies]. +/// +/// See also: +/// +/// * [StreamMessageReplies], which uses these properties. +class StreamMessageRepliesProps { + /// Creates properties for a message replies row. + const StreamMessageRepliesProps({ + this.label, + this.avatars, + this.avatarSize = .sm, + this.maxAvatars = 3, + this.showConnector = true, + this.onTap, + this.onLongPress, + this.alignment, + this.clipBehavior, + this.style, + }); + + /// An optional label widget, typically a [Text] showing the reply count + /// (e.g. "3 replies"). + /// + /// Styled by [StreamMessageRepliesStyle.labelTextStyle] and + /// [StreamMessageRepliesStyle.labelColor]. + final Widget? label; + + /// Avatar widgets for thread participants. + /// + /// Displayed as an overlapping stack. The size of each avatar is controlled + /// by [avatarSize] and the number of visible avatars by [maxAvatars]. + final Iterable? avatars; + + /// The size of each avatar in the stack. + /// + /// Defaults to [StreamAvatarStackSize.sm] (24px). + final StreamAvatarStackSize avatarSize; + + /// Maximum number of avatars to display before showing an overflow badge. + /// + /// Defaults to 3. + final int maxAvatars; + + /// Whether to show the connector linking this row to the message bubble. + /// + /// The connector appearance is controlled by + /// [StreamMessageRepliesStyle.connectorColor] and + /// [StreamMessageRepliesStyle.connectorStrokeWidth]. + /// + /// The connector adapts to [alignment] and [TextDirection] to always + /// point toward the message bubble. + final bool showConnector; + + /// Called when the replies row is tapped, typically to navigate to the + /// thread view. + final VoidCallback? onTap; + + /// Called when the replies row is long-pressed. + final VoidCallback? onLongPress; + + /// Controls the semantic order of elements in the row. + /// + /// See [StreamMessageAlignment] for details on how this composes with + /// [TextDirection]. + final StreamMessageAlignment? alignment; + + /// How to clip the widget's content. + /// + /// Useful when the connector overflows the row bounds. Set to + /// [Clip.hardEdge] or similar to constrain the visible area. + /// + /// When null, falls back to the theme's [StreamMessageRepliesStyle.clipBehavior], + /// which defaults based on stack position. + final Clip? clipBehavior; + + /// Optional style overrides for placement-aware styling. + /// + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageRepliesStyle? style; +} + +/// The default implementation of [StreamMessageReplies]. +/// +/// See also: +/// +/// * [StreamMessageReplies], the public API widget. +/// * [StreamMessageRepliesProps], which configures this widget. +class DefaultStreamMessageReplies extends StatelessWidget { + /// Creates a default message replies row with the given [props]. + const DefaultStreamMessageReplies({super.key, required this.props}); + + /// The properties that configure this replies row. + final StreamMessageRepliesProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final repliesStyle = props.style ?? StreamMessageItemTheme.of(context).replies; + final defaults = _StreamMessageRepliesDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [repliesStyle, defaults]); + + final effectiveLabelTextStyle = resolve((s) => s?.labelTextStyle); + final effectiveLabelColor = resolve((s) => s?.labelColor); + final effectiveSpacing = resolve((s) => s?.spacing); + final effectivePadding = resolve((s) => s?.padding); + final effectiveAlignment = props.alignment ?? placement.alignment; + + Widget? labelWidget; + if (props.label case final label?) { + labelWidget = Flexible( + child: AnimatedDefaultTextStyle( + style: effectiveLabelTextStyle.copyWith(color: effectiveLabelColor), + duration: kThemeChangeDuration, + child: label, + ), + ); + } + + Widget? avatarsWidget; + if (props.avatars case final avatars? when avatars.isNotEmpty) { + avatarsWidget = StreamAvatarStack( + size: props.avatarSize, + max: props.maxAvatars, + children: avatars, + ); + } + + Widget? connectorWidget; + if (props.showConnector) { + final effectiveConnectorColor = resolve((s) => s?.connectorColor); + final effectiveStrokeWidth = resolve((s) => s?.connectorStrokeWidth); + + connectorWidget = CustomPaint( + size: const Size(_kConnectorWidth, _kConnectorHeight), + painter: _ConnectorPainter( + color: effectiveConnectorColor, + strokeWidth: effectiveStrokeWidth, + alignment: effectiveAlignment, + textDirection: Directionality.of(context), + ), + ); + } + + final children = switch (effectiveAlignment) { + .start => [?connectorWidget, ?avatarsWidget, ?labelWidget], + .end => [?labelWidget, ?avatarsWidget, ?connectorWidget], + }; + + Widget child = Padding( + padding: effectivePadding, + child: Row(mainAxisSize: .min, spacing: effectiveSpacing, children: children), + ); + + final effectiveClip = resolve((s) => s?.clipBehavior); + if (effectiveClip != Clip.none) { + child = ClipRect(clipBehavior: effectiveClip, child: child); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: props.onTap, + onLongPress: props.onLongPress, + child: child, + ); + } +} + +// The layout slot size for the connector (from Figma: 16x24px). +const double _kConnectorWidth = 16; +const double _kConnectorHeight = 24; + +// The y-coordinate of the path exit point in the SVG (where the curve +// ends horizontally). Used to translate the canvas so the exit aligns +// with the vertical center of the layout slot. +const double _kConnectorExitY = 36; + +class _ConnectorPainter extends CustomPainter { + _ConnectorPainter({ + required this.color, + required this.strokeWidth, + required this.alignment, + required this.textDirection, + }); + + final Color color; + final double strokeWidth; + final StreamMessageAlignment alignment; + final TextDirection textDirection; + + @override + void paint(Canvas canvas, Size size) { + final roundedStroke = strokeWidth.roundToDouble(); + final paint = Paint() + ..color = color + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..strokeWidth = roundedStroke; + + // Translate so the path exit point (y=36 in the SVG) aligns with the + // vertical center of the paint area. This lets the Row's default + // CrossAxisAlignment.center naturally align the connector exit with + // the avatar center — no hardcoded offset needed. + canvas.translate(0, (size.height / 2) - _kConnectorExitY); + + // The connector curves inward toward the content. The curve direction + // depends on which physical side the connector sits on, determined by + // both alignment and text direction. Mirror the canvas when flipped. + final isFlipped = switch ((alignment, textDirection)) { + (StreamMessageAlignment.start, TextDirection.rtl) => true, + (StreamMessageAlignment.end, TextDirection.ltr) => true, + _ => false, + }; + + if (isFlipped) { + canvas.translate(size.width, 0); + canvas.scale(-1, 1); + } + + // Snap the vertical line to pixel boundaries to avoid anti-aliasing blur. + final isOddStroke = roundedStroke.toInt().isOdd; + final lineX = isOddStroke ? 0.5 : 0.0; + + // Figma SVG path (16x37): M16 36 C7.44 36 0.5 29.06 0.5 20.5 L0.5 0 + final path = Path() + ..moveTo(16, 36) + ..cubicTo(7.43959, 36, lineX, 29.0604, lineX, 20.5) + ..lineTo(lineX, 0); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_ConnectorPainter oldDelegate) => + color != oldDelegate.color || + strokeWidth != oldDelegate.strokeWidth || + alignment != oldDelegate.alignment || + textDirection != oldDelegate.textDirection; +} + +class _StreamMessageRepliesDefaults extends StreamMessageRepliesStyle { + _StreamMessageRepliesDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + StreamMessageStyleProperty get connectorStrokeWidth => .all(1); + + @override + StreamMessageStyleProperty get connectorColor => .resolveWith( + (placement) => switch (placement.alignment) { + .start => _colorScheme.borderSubtle, + .end => _colorScheme.brand.shade150, + }, + ); + + @override + StreamMessageStyleProperty get labelTextStyle => .all(_textTheme.captionEmphasis); + + @override + StreamMessageStyleProperty get labelColor => .all(_colorScheme.textLink); + + @override + StreamMessageStyleProperty get spacing => .all(_spacing.xs); + + @override + StreamMessageStyleProperty get padding => .all(.only(top: _spacing.xs, bottom: _spacing.xxs)); + + @override + StreamMessageStyleClip get clipBehavior => .resolveWith( + (placement) => switch (placement.stackPosition) { + .top || .middle => Clip.none, + .bottom || .single => Clip.hardEdge, + }, + ); +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart new file mode 100644 index 0000000..db31088 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart @@ -0,0 +1,415 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:stream_core/stream_core.dart'; + +import '../../theme.dart'; +import '../accessories/stream_emoji.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// The default protocol prefix used to identify mention links. +/// +/// Links with this scheme (e.g., `[text](mention:id)`) are treated as +/// mentions rather than regular links. +const kStreamMentionScheme = 'mention'; + +/// Callback fired when a mention link is tapped. +/// +/// [displayText] is the raw display text from the link +/// (e.g., `'@Alice'` from `[@Alice](mention:user123)`). +/// [id] is the mention identifier (the URL-decoded portion after the +/// `mention:` scheme). +typedef MarkdownTapMentionCallback = void Function(String displayText, String id); + +// Matches characters that render as emoji — either those with default emoji +// presentation, or text-default characters forced to emoji via VS16 (U+FE0F). +// +// Uses Unicode property escapes so new emoji are covered automatically when +// Dart's ICU tables update, with no hardcoded code-point ranges to maintain. +final _emojiRegex = RegExp(r'\p{Emoji_Presentation}|\p{Emoji}\uFE0F', unicode: true); + +/// Renders markdown text with themed styling. +/// +/// {@tool snippet} +/// +/// Basic markdown rendering: +/// +/// ```dart +/// StreamMessageText('**Hello** _world_') +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom code handling and mention support: +/// +/// ```dart +/// StreamMessageText( +/// responseMarkdown, +/// syntaxHighlighter: mySyntaxHighlighter, +/// builders: {'pre': MyCodeBlockBuilder()}, +/// onTapLink: (text, href, title) => launchUrl(Uri.parse(href ?? '')), +/// onTapMention: (displayText, id) => navigateToProfile(id), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageTextStyle], for customizing text appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. +/// * [kStreamMentionScheme], the protocol prefix used for mention detection. +class StreamMessageText extends StatelessWidget { + /// Creates a markdown message text widget. + StreamMessageText( + String text, { + super.key, + StreamMessageTextStyle? style, + bool selectable = false, + MarkdownTapLinkCallback? onTapLink, + MarkdownTapMentionCallback? onTapMention, + VoidCallback? onTapText, + MarkdownImageBuilder? imageBuilder, + SyntaxHighlighter? syntaxHighlighter, + Map? builders, + Map? paddingBuilders, + List? blockSyntaxes, + List? inlineSyntaxes, + md.ExtensionSet? extensionSet, + bool softLineBreak = false, + bool fitContent = true, + MarkdownStyleSheet? styleSheet, + }) : props = .new( + text: text, + style: style, + selectable: selectable, + onTapLink: onTapLink, + onTapMention: onTapMention, + onTapText: onTapText, + imageBuilder: imageBuilder, + syntaxHighlighter: syntaxHighlighter, + builders: builders, + paddingBuilders: paddingBuilders, + blockSyntaxes: blockSyntaxes, + inlineSyntaxes: inlineSyntaxes, + extensionSet: extensionSet, + softLineBreak: softLineBreak, + fitContent: fitContent, + styleSheet: styleSheet, + ); + + /// The properties that configure this widget. + final StreamMessageTextProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageText; + if (builder != null) return builder(context, props); + return DefaultStreamMessageText(props: props); + } + + /// Returns the number of emoji grapheme clusters if [text] contains only + /// emojis (ignoring whitespace), or `null` if the text is empty or contains + /// any non-emoji characters. + /// + /// Useful for determining emoji-specific rendering such as larger font sizes + /// or hiding the message bubble. + /// + /// ```dart + /// StreamMessageText.emojiOnlyCount('🚀') // 1 + /// StreamMessageText.emojiOnlyCount('👍🔥') // 2 + /// StreamMessageText.emojiOnlyCount('❤️🎉😍') // 3 + /// StreamMessageText.emojiOnlyCount('🎉🎉🎉🎉') // 4 + /// StreamMessageText.emojiOnlyCount('Hello 👋') // null (mixed) + /// StreamMessageText.emojiOnlyCount('👨‍👩‍👧') // 1 (ZWJ family) + /// StreamMessageText.emojiOnlyCount('🇺🇸') // 1 (flag) + /// StreamMessageText.emojiOnlyCount('👍🏽') // 1 (skin tone) + /// ``` + static int? emojiOnlyCount(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return null; + + final graphemes = trimmed.characters.where((c) => c.trim().isNotEmpty); + + for (final grapheme in graphemes) { + if (!_emojiRegex.hasMatch(grapheme)) return null; + } + + return graphemes.length; + } +} + +/// Properties for configuring a [StreamMessageText]. +/// +/// See also: +/// +/// * [StreamMessageText], which uses these properties. +/// * [DefaultStreamMessageText], the default implementation. +@immutable +class StreamMessageTextProps { + /// Creates properties for a markdown message text widget. + const StreamMessageTextProps({ + required this.text, + this.style, + this.selectable = false, + this.onTapLink, + this.onTapMention, + this.onTapText, + this.imageBuilder, + this.syntaxHighlighter, + this.builders, + this.paddingBuilders, + this.blockSyntaxes, + this.inlineSyntaxes, + this.extensionSet, + this.softLineBreak = false, + this.fitContent = true, + this.styleSheet, + }); + + /// The markdown text to render. + final String text; + + /// Style override for text, links, and mentions. + final StreamMessageTextStyle? style; + + /// Whether text is selectable. + final bool selectable; + + /// Called when a link is tapped. + final MarkdownTapLinkCallback? onTapLink; + + /// Called when a mention is tapped. + /// + /// Mentions use the `[text](mention:id)` format. + final MarkdownTapMentionCallback? onTapMention; + + /// Called when non-link text is tapped. + final VoidCallback? onTapText; + + /// Custom image builder. + final MarkdownImageBuilder? imageBuilder; + + /// Syntax highlighter for code blocks. + final SyntaxHighlighter? syntaxHighlighter; + + /// Custom element builders keyed by tag name. + final Map? builders; + + /// Custom padding builders keyed by tag name. + final Map? paddingBuilders; + + /// Additional block-level syntax parsers. + final List? blockSyntaxes; + + /// Additional inline-level syntax parsers. + final List? inlineSyntaxes; + + /// Markdown extension set. + final md.ExtensionSet? extensionSet; + + /// Whether soft line breaks are treated as hard breaks. + final bool softLineBreak; + + /// Whether the widget sizes to fit its content. + final bool fitContent; + + /// Additional style sheet for customising headings, code blocks, tables, + /// and other markdown styles not exposed in [StreamMessageTextStyle]. + final MarkdownStyleSheet? styleSheet; +} + +/// The default implementation of [StreamMessageText]. +/// +/// See also: +/// +/// * [StreamMessageText], the public API widget. +/// * [StreamMessageTextProps], which configures this widget. +class DefaultStreamMessageText extends StatelessWidget { + /// Creates a default message text widget with the given [props]. + const DefaultStreamMessageText({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamMessageTextProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final textStyleFromTheme = props.style ?? StreamMessageItemTheme.of(context).text; + final defaults = _StreamMessageTextDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [textStyleFromTheme, defaults]); + + final effectiveTextColor = resolve((s) => s?.textColor); + var effectiveTextStyle = resolve((s) => s?.textStyle).copyWith(color: effectiveTextColor); + final effectiveLinkColor = resolve((s) => s?.linkColor); + final effectiveLinkStyle = resolve((s) => s?.linkStyle).copyWith(color: effectiveLinkColor); + final effectiveMentionColor = resolve((s) => s?.mentionColor); + final effectiveMentionStyle = resolve((s) => s?.mentionStyle).copyWith(color: effectiveMentionColor); + + final emojiCount = StreamMessageText.emojiOnlyCount(props.text); + if (emojiCount case final count?) { + final emojiStyle = switch (count) { + 1 => resolve((s) => s?.singleEmojiStyle), + 2 => resolve((s) => s?.doubleEmojiStyle), + 3 => resolve((s) => s?.tripleEmojiStyle), + _ => null, // No emoji style (Fallback to regular style) + }; + + effectiveTextStyle = effectiveTextStyle.merge(emojiStyle); + } + + final streamThemeData = Theme.of(context).let( + (it) => it.copyWith( + textTheme: it.textTheme.apply( + bodyColor: effectiveTextStyle.color, + decoration: effectiveTextStyle.decoration, + decorationColor: effectiveTextStyle.decorationColor, + decorationStyle: effectiveTextStyle.decorationStyle, + fontFamily: effectiveTextStyle.fontFamily, + fontFamilyFallback: effectiveTextStyle.fontFamilyFallback, + ), + ), + ); + + final markdownSheet = MarkdownStyleSheet.fromTheme( + streamThemeData, // Apply stream theme data + ).copyWith(p: effectiveTextStyle, a: effectiveLinkStyle).merge(props.styleSheet); + + // Prepend mention syntax so `[text](mention:id)` is intercepted + // before the standard LinkSyntax, producing `mention` elements. + // Regular `a` elements are never touched. + final mentionStyle = effectiveMentionStyle.copyWith(color: effectiveMentionColor); + + final effectiveInlineSyntaxes = [ + _StreamMentionSyntax(), + ...?props.inlineSyntaxes, + ]; + + final effectiveBuilders = { + kStreamMentionScheme: _StreamMentionBuilder( + style: mentionStyle, + onTap: props.onTapMention, + ), + ...?props.builders, + }; + + return MarkdownBody( + data: props.text, + selectable: props.selectable, + styleSheet: markdownSheet, + styleSheetTheme: .platform, + syntaxHighlighter: props.syntaxHighlighter, + onTapLink: props.onTapLink, + onTapText: props.onTapText, + imageBuilder: props.imageBuilder, + builders: effectiveBuilders, + paddingBuilders: props.paddingBuilders ?? const {}, + blockSyntaxes: props.blockSyntaxes, + inlineSyntaxes: effectiveInlineSyntaxes, + extensionSet: props.extensionSet, + softLineBreak: props.softLineBreak, + fitContent: props.fitContent, + ); + } +} + +// Intercepts `[text](mention:id)` patterns before the standard link parser, +// emitting a `mention` element instead of a regular link. +// +// Given `[@Alice](mention:user123)`: +// +// * Emits a `mention` element with text content `@Alice`. +// * Stores the URL-decoded id (`user123`) in the `id` attribute. +// * Regular links are never touched. +class _StreamMentionSyntax extends md.InlineSyntax { + _StreamMentionSyntax({ + String scheme = kStreamMentionScheme, + }) : super('\\[([^\\]\\n]+)\\]\\(${RegExp.escape(scheme)}:([^)\\s]+)\\)'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final displayText = match.group(1)!; + final rawId = match.group(2)!; + + final el = md.Element.text('mention', displayText); + el.attributes['id'] = Uri.decodeComponent(rawId); + parser.addNode(el); + return true; + } +} + +// Renders `mention` elements as tappable styled text with pointer cursor. +class _StreamMentionBuilder extends MarkdownElementBuilder { + _StreamMentionBuilder({required this.style, this.onTap}); + + final TextStyle style; + final MarkdownTapMentionCallback? onTap; + + @override + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final displayText = element.textContent; + final id = element.attributes['id'] ?? ''; + + return MouseRegion( + cursor: onTap != null ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + onTap: onTap != null ? () => onTap!(displayText, id) : null, + child: Text(displayText, style: preferredStyle?.merge(style) ?? style), + ), + ); + } +} + +// Default values for [StreamMessageTextStyle] backed by stream design tokens. +class _StreamMessageTextDefaults extends StreamMessageTextStyle { + _StreamMessageTextDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + + @override + StreamMessageStyleProperty get textStyle => .all(_textTheme.bodyDefault); + + @override + StreamMessageStyleProperty get textColor => .resolveWith( + (placement) => switch (placement.alignment) { + .start => _colorScheme.textPrimary, + .end => _colorScheme.brand.shade900, + }, + ); + + @override + StreamMessageStyleProperty get linkStyle => .all(_textTheme.bodyLink); + + @override + StreamMessageStyleProperty get linkColor => .all(_colorScheme.textLink); + + @override + StreamMessageStyleProperty get mentionStyle => .all(_textTheme.bodyLink); + + @override + StreamMessageStyleProperty get mentionColor => .all(_colorScheme.textLink); + + @override + StreamMessageStyleProperty get singleEmojiStyle { + return .all(.new(fontSize: StreamEmojiSize.xxl.value, height: 1)); + } + + @override + StreamMessageStyleProperty get doubleEmojiStyle { + return .all(.new(fontSize: StreamEmojiSize.xl.value, height: 1)); + } + + @override + StreamMessageStyleProperty get tripleEmojiStyle { + return .all(.new(fontSize: StreamEmojiSize.lg.value, height: 1)); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart new file mode 100644 index 0000000..b477512 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; +import '../common/stream_visibility.dart'; +import '../message_placement/stream_channel_kind.dart'; +import '../message_placement/stream_message_alignment.dart'; +import '../message_placement/stream_message_placement.dart'; +import '../message_placement/stream_message_stack_position.dart'; + +/// The top-level message item widget used directly by message lists. +/// +/// [StreamMessageWidget] composes a leading slot (typically an avatar) +/// alongside a content slot, and establishes the [StreamMessagePlacement] +/// that descendant message sub-components use for placement-aware styling. +/// +/// {@tool snippet} +/// +/// Incoming message with avatar and content: +/// +/// ```dart +/// StreamMessageWidget( +/// leading: StreamAvatar( +/// imageUrl: user.avatarUrl, +/// placeholder: (context) => Text(user.initials), +/// ), +/// child: StreamMessageContent( +/// footer: StreamMessageMetadata(timestamp: Text('09:41')), +/// child: StreamMessageBubble( +/// child: StreamMessageText('Hello, world!'), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Outgoing message without avatar: +/// +/// ```dart +/// StreamMessageWidget( +/// alignment: StreamMessageAlignment.end, +/// child: StreamMessageContent( +/// footer: StreamMessageMetadata(timestamp: Text('09:42')), +/// child: StreamMessageBubble( +/// child: StreamMessageText('Hey there!'), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageContent], for composing bubble, annotations, and metadata. +/// * [StreamMessagePlacement], the placement context this widget +/// establishes for descendants. +/// * [StreamMessageItemTheme], for theming message items. +class StreamMessageWidget extends StatelessWidget { + /// Creates a message item. + /// + /// The [child] is required. An optional [leading] widget is displayed + /// alongside the content. The [alignment], [stackPosition], and + /// [channelKind] configure the [StreamMessagePlacement] for descendants. + StreamMessageWidget({ + super.key, + Widget? leading, + required Widget child, + StreamMessageAlignment alignment = .start, + StreamMessageStackPosition stackPosition = .single, + StreamChannelKind channelKind = .group, + StreamVisibility? leadingVisibility, + EdgeInsetsGeometry? padding, + double? spacing, + VoidCallback? onTap, + VoidCallback? onLongPress, + }) : props = .new( + leading: leading, + child: child, + alignment: alignment, + stackPosition: stackPosition, + channelKind: channelKind, + leadingVisibility: leadingVisibility, + padding: padding, + spacing: spacing, + onTap: onTap, + onLongPress: onLongPress, + ); + + /// The properties that configure this message item. + final StreamMessageWidgetProps props; + + @override + Widget build(BuildContext context) => StreamMessagePlacement( + placement: StreamMessagePlacementData( + alignment: props.alignment, + stackPosition: props.stackPosition, + channelKind: props.channelKind, + ), + child: Builder( + builder: (context) { + final builder = StreamComponentFactory.of(context).messageWidget; + if (builder != null) return builder(context, props); + return DefaultStreamMessageWidget(props: props); + }, + ), + ); +} + +/// Properties for configuring a [StreamMessageWidget]. +/// +/// See also: +/// +/// * [StreamMessageWidget], which uses these properties. +class StreamMessageWidgetProps { + /// Creates properties for a message item. + const StreamMessageWidgetProps({ + this.leading, + required this.child, + this.alignment = .start, + this.stackPosition = .single, + this.channelKind = .group, + this.leadingVisibility, + this.padding, + this.spacing, + this.onTap, + this.onLongPress, + }); + + /// Optional widget displayed alongside the content. + /// + /// Typically an avatar. Positioned at the start or end of the row + /// depending on [alignment]. + /// + /// When null, no leading widget is shown and no space is reserved. + final Widget? leading; + + /// The main content of the message item. + /// + /// Typically a [StreamMessageContent] composing bubble, annotations, + /// metadata, and reactions. + final Widget child; + + /// The horizontal alignment of the message. + /// + /// Determines the element order in the row and establishes the + /// [StreamMessagePlacement] alignment for descendants. + /// + /// Defaults to [StreamMessageAlignment.start]. + final StreamMessageAlignment alignment; + + /// The position of this message within a consecutive stack. + /// + /// Establishes the [StreamMessagePlacement] stack position for + /// descendants, which sub-components use to adjust visual treatment + /// (e.g. corner radii). + /// + /// Defaults to [StreamMessageStackPosition.single]. + final StreamMessageStackPosition stackPosition; + + /// The kind of channel this message is displayed in. + /// + /// Establishes the [StreamMessagePlacement] channel kind for + /// descendants, which sub-components use to adapt their appearance + /// (e.g. hiding avatars in direct channels). + /// + /// Defaults to [StreamChannelKind.group]. + final StreamChannelKind channelKind; + + /// Overrides the leading widget visibility for this message item. + /// + /// When non-null, takes precedence over the theme-resolved value from + /// [StreamMessageItemThemeData.leadingVisibility]. + /// + /// When null (the default), the visibility is determined by the theme. + final StreamVisibility? leadingVisibility; + + /// Outer padding around the entire message item. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.padding]. + /// + /// When null (the default), the padding is determined by the theme. + final EdgeInsetsGeometry? padding; + + /// Horizontal spacing between the leading avatar and the content. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.spacing]. + /// + /// When null (the default), the spacing is determined by the theme. + final double? spacing; + + /// Called when the message item is tapped. + final VoidCallback? onTap; + + /// Called when the message item is long-pressed. + final VoidCallback? onLongPress; +} + +/// The default implementation of [StreamMessageWidget]. +/// +/// See also: +/// +/// * [StreamMessageWidget], the public API widget. +/// * [StreamMessageWidgetProps], which configures this widget. +class DefaultStreamMessageWidget extends StatelessWidget { + /// Creates a default message item with the given [props]. + const DefaultStreamMessageWidget({super.key, required this.props}); + + /// The properties that configure this message item. + final StreamMessageWidgetProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final theme = StreamMessageItemTheme.of(context); + final defaults = _StreamMessageWidgetDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [theme, defaults]); + + final effectivePadding = props.padding ?? theme.padding ?? defaults.padding; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + final effectiveBackgroundColor = theme.backgroundColor ?? StreamColors.transparent; + final effectiveLeadingVisibility = props.leadingVisibility ?? resolve((theme) => theme?.leadingVisibility); + + Widget? leadingWidget; + if (props.leading case final leading?) { + final effectiveAvatarSize = theme.avatarSize ?? defaults.avatarSize; + + leadingWidget = StreamAvatarTheme( + data: .new(size: effectiveAvatarSize), + child: leading, + ); + + leadingWidget = switch (effectiveLeadingVisibility) { + StreamVisibility.visible => leadingWidget, + StreamVisibility.hidden => Visibility.maintain(visible: false, child: leadingWidget), + StreamVisibility.gone => null, + }; + } + + final content = Flexible(child: props.child); + + final children = switch (props.alignment) { + StreamMessageAlignment.start => [?leadingWidget, content], + StreamMessageAlignment.end => [content, ?leadingWidget], + }; + + return Material( + animateColor: true, + color: effectiveBackgroundColor, + child: InkWell( + onTap: props.onTap, + onLongPress: props.onLongPress, + child: Padding( + padding: effectivePadding, + child: Row( + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: children, + ), + ), + ), + ); + } +} + +class _StreamMessageWidgetDefaults extends StreamMessageItemThemeData { + _StreamMessageWidgetDefaults(this._context); + + final BuildContext _context; + + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + double get spacing => _spacing.xs; + + @override + StreamAvatarSize get avatarSize => .md; + + @override + EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.md); + + @override + StreamMessageStyleVisibility get leadingVisibility => .resolveWith( + (placement) => switch ((placement.channelKind, placement.alignment, placement.stackPosition)) { + (.direct, _, _) || (_, .end, _) => .gone, + (_, _, .top || .middle) => .hidden, + (_, _, .single || .bottom) => .visible, + }, + ); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart new file mode 100644 index 0000000..6295a40 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart @@ -0,0 +1,17 @@ +/// The kind of channel a message is displayed in. +/// +/// Used by [StreamMessagePlacementData] to let descendant widgets adapt their +/// appearance based on the channel type — for example, hiding avatars in +/// direct (1-to-1) conversations where the sender is always known. +/// +/// See also: +/// +/// * [StreamMessagePlacementData], which carries this value. +/// * [StreamMessagePlacement], the [InheritedModel] that provides it. +enum StreamChannelKind { + /// A direct (1-to-1) conversation between two users. + direct, + + /// A group conversation with multiple participants. + group, +} diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_alignment.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_alignment.dart new file mode 100644 index 0000000..b456f7c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_alignment.dart @@ -0,0 +1,26 @@ +/// Controls the semantic order of elements in message-related row components +/// based on which side the message bubble is aligned to. +/// +/// This is orthogonal to [TextDirection] (LTR/RTL). The alignment determines +/// the children order, while the ambient [TextDirection] determines the visual +/// rendering direction. They compose naturally: +/// +/// | Alignment | TextDirection | Visual result | +/// |-----------|--------------|---------------------------------------| +/// | start | LTR | start-ordered children, left-to-right | +/// | start | RTL | start-ordered children, right-to-left | +/// | end | LTR | end-ordered children, left-to-right | +/// | end | RTL | end-ordered children, right-to-left | +/// +/// The caller decides which alignment to use based on their app's message +/// layout configuration (all-start, all-end, or directional per sender). +/// +/// Each widget that accepts this enum defines its own element order for +/// [start] and [end]. See the individual widget documentation for specifics. +enum StreamMessageAlignment { + /// Elements ordered toward the start of the message bubble. + start, + + /// Elements ordered toward the end of the message bubble. + end, +} diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart new file mode 100644 index 0000000..3cb6e67 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart @@ -0,0 +1,248 @@ +import 'package:flutter/widgets.dart'; + +import 'stream_channel_kind.dart'; +import 'stream_message_alignment.dart'; +import 'stream_message_stack_position.dart'; + +// The aspect of a [StreamMessagePlacementData] that a widget depends on. +// +// Used by [StreamMessagePlacement] (an [InheritedModel]) to provide +// fine-grained rebuild control. Widgets that only care about one axis can +// subscribe to just that aspect and skip rebuilds when the other changes. +enum _StreamMessagePlacementAspect { + // The horizontal alignment axis (start / end). + alignment, + + // The vertical stack position axis (single / top / middle / bottom). + stackPosition, + + // The channel kind (direct / group). + channelKind, +} + +/// Provides [StreamMessagePlacementData] to descendant widgets. +/// +/// Descendants can read the placement using one of the static methods: +/// +/// * [of] — returns the full placement (rebuilds when either axis changes). +/// * [alignmentOf] — returns only the alignment (ignores stack position +/// changes). +/// * [crossAxisAlignmentOf] — returns a [CrossAxisAlignment] derived from +/// the alignment (ignores stack position changes). +/// * [stackPositionOf] — returns only the stack position (ignores alignment +/// changes). +/// * [channelKindOf] — returns only the channel kind (ignores alignment and +/// stack position changes). +/// +/// When no [StreamMessagePlacement] is found in the tree, a default placement +/// of [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] + +/// [StreamChannelKind.group] is returned. +/// +/// {@tool snippet} +/// +/// Read the full placement in a sub-component: +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// final placement = StreamMessagePlacement.of(context); +/// final shape = style?.shape?.resolve(placement) +/// ?? defaults.shape.resolve(placement); +/// // ... +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessagePlacementData], the data this widget provides. +class StreamMessagePlacement extends InheritedModel<_StreamMessagePlacementAspect> { + /// Creates a placement scope that provides [placement] to descendants. + const StreamMessagePlacement({ + super.key, + required this.placement, + required super.child, + }); + + /// The message placement provided to descendants. + final StreamMessagePlacementData placement; + + /// The data from the closest instance of this class that encloses the given + /// context. + /// + /// You can use this function to query the entire + /// [StreamMessagePlacementData]. When any of that information changes, your + /// widget will be scheduled to be rebuilt, keeping your widget up-to-date. + /// + /// Since it is typical that the widget only requires a subset of properties + /// of the [StreamMessagePlacementData], prefer using the more specific + /// methods (for example: [alignmentOf] and [stackPositionOf]), as those + /// methods will not cause a widget to rebuild when unrelated properties are + /// updated. + /// + /// If there is no [StreamMessagePlacement] in scope, a default placement of + /// [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] is + /// returned. + static StreamMessagePlacementData of(BuildContext context) => _of(context); + + static StreamMessagePlacementData _of(BuildContext context, [_StreamMessagePlacementAspect? aspect]) { + final placement = InheritedModel.inheritFrom(context, aspect: aspect)?.placement; + if (placement != null) return placement; + return const StreamMessagePlacementData(); + } + + /// Returns [StreamMessagePlacementData.alignment] from the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.alignment] property of the ancestor + /// [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamMessageAlignment alignmentOf(BuildContext context) { + return _of(context, _StreamMessagePlacementAspect.alignment).alignment; + } + + /// Returns a [CrossAxisAlignment] derived from + /// [StreamMessagePlacementData.alignment] of the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// [StreamMessageAlignment.start] maps to [CrossAxisAlignment.start] and + /// [StreamMessageAlignment.end] maps to [CrossAxisAlignment.end]. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.alignment] property of the ancestor + /// [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static CrossAxisAlignment crossAxisAlignmentOf(BuildContext context) { + return switch (alignmentOf(context)) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + } + + /// Returns [StreamMessagePlacementData.stackPosition] from the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.stackPosition] property of the + /// ancestor [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamMessageStackPosition stackPositionOf(BuildContext context) { + return _of(context, _StreamMessagePlacementAspect.stackPosition).stackPosition; + } + + /// Returns [StreamMessagePlacementData.channelKind] from the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.channelKind] property of the + /// ancestor [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamChannelKind channelKindOf(BuildContext context) { + return _of(context, _StreamMessagePlacementAspect.channelKind).channelKind; + } + + @override + bool updateShouldNotify(StreamMessagePlacement oldWidget) => placement != oldWidget.placement; + + @override + bool updateShouldNotifyDependent( + StreamMessagePlacement oldWidget, + Set dependencies, + ) => dependencies.any( + (dependency) { + if (dependency is! _StreamMessagePlacementAspect) return false; + return switch (dependency) { + .alignment => placement.alignment != oldWidget.placement.alignment, + .stackPosition => placement.stackPosition != oldWidget.placement.stackPosition, + .channelKind => placement.channelKind != oldWidget.placement.channelKind, + }; + }, + ); +} + +/// Describes where a message sits within the message list layout. +/// +/// Combines [alignment] (start vs end), [stackPosition] +/// (single, top, middle, bottom), and [channelKind] (direct vs group) into a +/// single value that [StreamMessageStyleProperty] resolvers use to compute +/// placement-dependent styling. +/// +/// {@tool snippet} +/// +/// Create a placement for an end-aligned message at the top of a stack in a +/// group channel: +/// +/// ```dart +/// const placement = StreamMessagePlacementData( +/// alignment: StreamMessageAlignment.end, +/// stackPosition: StreamMessageStackPosition.top, +/// channelKind: StreamChannelKind.group, +/// ); +/// +/// print(placement.alignment); // StreamMessageAlignment.end +/// print(placement.stackPosition); // StreamMessageStackPosition.top +/// print(placement.channelKind); // StreamChannelKind.group +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAlignment], the horizontal alignment axis. +/// * [StreamMessageStackPosition], the vertical stacking axis. +/// * [StreamChannelKind], the kind of channel the message is displayed in. +/// * [StreamMessageStyleProperty], which resolves values based on placement. +@immutable +class StreamMessagePlacementData { + /// Creates a message placement. + /// + /// Defaults to a start-aligned, standalone message in a group channel + /// ([StreamMessageAlignment.start] + [StreamMessageStackPosition.single] + + /// [StreamChannelKind.group]). + const StreamMessagePlacementData({ + this.alignment = .start, + this.stackPosition = .single, + this.channelKind = .group, + }); + + /// The horizontal alignment of the message. + final StreamMessageAlignment alignment; + + /// The position of the message within a consecutive stack. + final StreamMessageStackPosition stackPosition; + + /// The kind of channel this message is displayed in. + final StreamChannelKind channelKind; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is StreamMessagePlacementData && + other.alignment == alignment && + other.stackPosition == stackPosition && + other.channelKind == channelKind; + } + + @override + int get hashCode => Object.hash(alignment, stackPosition, channelKind); + + @override + String toString() => + 'StreamMessagePlacementData(alignment: $alignment, stackPosition: $stackPosition, channelKind: $channelKind)'; +} diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart new file mode 100644 index 0000000..37f2f86 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart @@ -0,0 +1,37 @@ +/// The position of a message within a consecutive stack from the same sender. +/// +/// Chat applications commonly group consecutive messages from the same user, +/// adjusting the bubble's visual treatment (typically corner radii) based on +/// where it sits in the group. +/// +/// {@tool snippet} +/// +/// Select a bubble border radius based on stack position: +/// +/// ```dart +/// final borderRadius = switch (stackPosition) { +/// .single => BorderRadius.circular(20), +/// .top => BorderRadius.vertical(top: Radius.circular(20)), +/// .middle => BorderRadius.zero, +/// .bottom => BorderRadius.vertical(bottom: Radius.circular(20)), +/// }; +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAlignment], which controls the horizontal placement of +/// message elements. +enum StreamMessageStackPosition { + /// A standalone message that is not part of any group. + single, + + /// The first message in a consecutive group. + top, + + /// A message in the middle of a consecutive group. + middle, + + /// The last message in a consecutive group. + bottom, +} diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index 268c9a8..ce41308 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -8,6 +8,8 @@ import '../../theme/stream_theme_extensions.dart'; import '../accessories/stream_emoji.dart'; import '../common/stream_flex.dart'; import '../controls/stream_emoji_chip.dart'; +import '../message_placement/stream_message_alignment.dart'; +import '../message_placement/stream_message_placement.dart'; /// Displays reactions as either individual chips or a single grouped chip. /// @@ -18,6 +20,10 @@ import '../controls/stream_emoji_chip.dart'; /// Reactions can be displayed on their own or positioned relative to a /// [child], such as a message bubble or container. /// +/// If a [StreamMessagePlacement] is found in the ancestor tree, +/// [position], [alignment], [crossAxisAlignment], and [indent] are +/// automatically derived from the message alignment when not explicitly set. +/// /// {@tool snippet} /// /// Display segmented reactions below a child: @@ -66,8 +72,8 @@ class StreamReactions extends StatelessWidget { super.key, required List items, Widget? child, - StreamReactionsPosition position = .footer, - StreamReactionsAlignment alignment = .start, + StreamReactionsPosition? position, + StreamReactionsAlignment? alignment, int? max, bool overlap = true, double? indent, @@ -93,8 +99,8 @@ class StreamReactions extends StatelessWidget { super.key, required List items, Widget? child, - StreamReactionsPosition position = .header, - StreamReactionsAlignment alignment = .end, + StreamReactionsPosition? position, + StreamReactionsAlignment? alignment, int? max, bool overlap = true, double? indent, @@ -148,9 +154,6 @@ class StreamReactions extends StatelessWidget { /// Properties for configuring [StreamReactions]. /// -/// This class holds the configuration for a reactions widget so it can be -/// passed through the [StreamComponentFactory]. -/// /// See also: /// /// * [StreamReactions], which uses these properties. @@ -162,8 +165,8 @@ class StreamReactionsProps { required this.type, required this.items, this.child, - required this.position, - required this.alignment, + this.position, + this.alignment, this.max, this.overlap = true, this.indent, @@ -186,10 +189,10 @@ class StreamReactionsProps { final Widget? child; /// The vertical position of the reactions relative to the child. - final StreamReactionsPosition position; + final StreamReactionsPosition? position; /// The horizontal alignment of the reactions relative to the child. - final StreamReactionsAlignment alignment; + final StreamReactionsAlignment? alignment; /// Maximum number of visible items. /// @@ -247,6 +250,7 @@ class StreamReactionsItem { } const _kMaxVisibleSegments = 4; +const _kDefaultStripIndent = 8.0; /// Default implementation of [StreamReactions]. /// @@ -285,16 +289,42 @@ class DefaultStreamReactions extends StatelessWidget { // Negative spacing when overlapping makes reactions overlap the child edge. final columnSpacing = props.overlap ? -effectiveOverlapExtent : effectiveGap; - final effectiveCrossAxisAlignment = props.crossAxisAlignment ?? CrossAxisAlignment.start; + // Use the message alignment from the ancestor scope to derive sensible + // defaults for position, alignment, cross-axis alignment, and indent. + final messageAlignment = StreamMessagePlacement.alignmentOf(context); + + var effectiveCrossAxisAlignment = props.crossAxisAlignment; + effectiveCrossAxisAlignment ??= switch (messageAlignment) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + + var effectiveAlignment = props.alignment; + effectiveAlignment ??= switch ((messageAlignment, props.overlap)) { + (StreamMessageAlignment.start, true) => StreamReactionsAlignment.end, + (StreamMessageAlignment.start, false) => StreamReactionsAlignment.start, + (StreamMessageAlignment.end, true) => StreamReactionsAlignment.start, + (StreamMessageAlignment.end, false) => StreamReactionsAlignment.end, + }; + + var effectiveIndent = props.indent; + effectiveIndent ??= switch ((effectiveAlignment, props.overlap)) { + (StreamReactionsAlignment.start, true) => effectiveIndent ?? -_kDefaultStripIndent, + (StreamReactionsAlignment.end, true) => effectiveIndent ?? _kDefaultStripIndent, + _ => effectiveIndent ?? 0, + }; - final effectiveIndent = props.indent ?? reactionTheme.indent ?? defaults.indent; - final indentedStrip = Transform.translate(offset: .new(effectiveIndent, 0), child: reactionStrip); + final effectiveIndentOffset = Offset(effectiveIndent, 0).directional(Directionality.maybeOf(context)); + final indentedStrip = Transform.translate(offset: effectiveIndentOffset, child: reactionStrip); - final alignedStrip = switch (props.alignment) { + final alignedStrip = switch (effectiveAlignment) { .start => Align(alignment: AlignmentDirectional.centerStart, child: indentedStrip), .end => Align(alignment: AlignmentDirectional.centerEnd, child: indentedStrip), }; + var effectivePosition = props.position; + effectivePosition ??= props.overlap ? StreamReactionsPosition.header : StreamReactionsPosition.footer; + // Reactions are always the LAST child so they paint on top of the child // when overlapping (later children have higher z-order in Flex layout). // For top-positioned reactions we flip verticalDirection so the column @@ -305,15 +335,14 @@ class DefaultStreamReactions extends StatelessWidget { spacing: columnSpacing, crossAxisAlignment: effectiveCrossAxisAlignment, clipBehavior: props.clipBehavior, - verticalDirection: switch (props.position) { + verticalDirection: switch (effectivePosition) { .header => VerticalDirection.up, .footer => VerticalDirection.down, }, children: [props.child!, alignedStrip], ); - if (props.overlap) return IntrinsicWidth(child: column); - return column; + return IntrinsicWidth(child: column); } Widget _buildSegmented(double itemSpacing, int maxVisible) { @@ -374,7 +403,13 @@ class _StreamReactionsThemeDefaults extends StreamReactionsThemeData { @override double get overlapExtent => _spacing.xs; +} - @override - double get indent => 0; +/// Adapts an [Offset] for the current [TextDirection]. +extension on Offset { + /// Flips [dx] for RTL so a positive offset always means "toward trailing." + Offset directional([TextDirection? textDirection]) { + if (textDirection == null || textDirection == .ltr) return this; + return Offset(-dx, dy); + } } 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 a0c3a68..c1c866f 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 @@ -145,6 +145,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? emojiChipBar, StreamComponentBuilder? fileTypeIcon, StreamComponentBuilder? listTile, + StreamComponentBuilder? messageAnnotation, + StreamComponentBuilder? messageBubble, + StreamComponentBuilder? messageContent, + StreamComponentBuilder? messageMetadata, + StreamComponentBuilder? messageReplies, + StreamComponentBuilder? messageText, + StreamComponentBuilder? messageWidget, StreamComponentBuilder? onlineIndicator, StreamComponentBuilder? progressBar, StreamComponentBuilder? reactions, @@ -167,6 +174,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { emojiChipBar: emojiChipBar, fileTypeIcon: fileTypeIcon, listTile: listTile, + messageAnnotation: messageAnnotation, + messageBubble: messageBubble, + messageContent: messageContent, + messageMetadata: messageMetadata, + messageReplies: messageReplies, + messageText: messageText, + messageWidget: messageWidget, onlineIndicator: onlineIndicator, progressBar: progressBar, reactions: reactions, @@ -190,6 +204,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.emojiChipBar, required this.fileTypeIcon, required this.listTile, + required this.messageAnnotation, + required this.messageBubble, + required this.messageContent, + required this.messageMetadata, + required this.messageReplies, + required this.messageText, + required this.messageWidget, required this.onlineIndicator, required this.progressBar, required this.reactions, @@ -286,6 +307,41 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamListTile] uses [DefaultStreamListTile]. final StreamComponentBuilder? listTile; + /// Custom builder for message annotation widgets. + /// + /// When null, [StreamMessageAnnotation] uses [DefaultStreamMessageAnnotation]. + final StreamComponentBuilder? messageAnnotation; + + /// Custom builder for message bubble widgets. + /// + /// When null, [StreamMessageBubble] uses [DefaultStreamMessageBubble]. + final StreamComponentBuilder? messageBubble; + + /// Custom builder for message content layout widgets. + /// + /// When null, [StreamMessageContent] uses [DefaultStreamMessageContent]. + final StreamComponentBuilder? messageContent; + + /// Custom builder for message metadata widgets. + /// + /// When null, [StreamMessageMetadata] uses [DefaultStreamMessageMetadata]. + final StreamComponentBuilder? messageMetadata; + + /// Custom builder for message replies widgets. + /// + /// When null, [StreamMessageReplies] uses [DefaultStreamMessageReplies]. + final StreamComponentBuilder? messageReplies; + + /// Custom builder for message text (markdown) widgets. + /// + /// When null, [StreamMessageText] uses [DefaultStreamMessageText]. + final StreamComponentBuilder? messageText; + + /// Custom builder for message widget (top-level message item). + /// + /// When null, [StreamMessageWidget] uses [DefaultStreamMessageWidget]. + final StreamComponentBuilder? messageWidget; + /// 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 2e76e62..00f330c 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 @@ -45,6 +45,13 @@ mixin _$StreamComponentBuilders { emojiChipBar: t < 0.5 ? a.emojiChipBar : b.emojiChipBar, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, listTile: t < 0.5 ? a.listTile : b.listTile, + messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, + messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, + messageContent: t < 0.5 ? a.messageContent : b.messageContent, + messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, + messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, + messageText: t < 0.5 ? a.messageText : b.messageText, + messageWidget: t < 0.5 ? a.messageWidget : b.messageWidget, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, progressBar: t < 0.5 ? a.progressBar : b.progressBar, reactions: t < 0.5 ? a.reactions : b.reactions, @@ -70,6 +77,14 @@ mixin _$StreamComponentBuilders { emojiChipBar, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamListTileProps)? listTile, + Widget Function(BuildContext, StreamMessageAnnotationProps)? + messageAnnotation, + Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, + Widget Function(BuildContext, StreamMessageContentProps)? messageContent, + Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, + Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, + Widget Function(BuildContext, StreamMessageTextProps)? messageText, + Widget Function(BuildContext, StreamMessageWidgetProps)? messageWidget, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, Widget Function(BuildContext, StreamProgressBarProps)? progressBar, Widget Function(BuildContext, StreamReactionsProps)? reactions, @@ -92,6 +107,13 @@ mixin _$StreamComponentBuilders { emojiChipBar: emojiChipBar ?? _this.emojiChipBar, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, listTile: listTile ?? _this.listTile, + messageAnnotation: messageAnnotation ?? _this.messageAnnotation, + messageBubble: messageBubble ?? _this.messageBubble, + messageContent: messageContent ?? _this.messageContent, + messageMetadata: messageMetadata ?? _this.messageMetadata, + messageReplies: messageReplies ?? _this.messageReplies, + messageText: messageText ?? _this.messageText, + messageWidget: messageWidget ?? _this.messageWidget, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, progressBar: progressBar ?? _this.progressBar, reactions: reactions ?? _this.reactions, @@ -125,6 +147,13 @@ mixin _$StreamComponentBuilders { emojiChipBar: other.emojiChipBar, fileTypeIcon: other.fileTypeIcon, listTile: other.listTile, + messageAnnotation: other.messageAnnotation, + messageBubble: other.messageBubble, + messageContent: other.messageContent, + messageMetadata: other.messageMetadata, + messageReplies: other.messageReplies, + messageText: other.messageText, + messageWidget: other.messageWidget, onlineIndicator: other.onlineIndicator, progressBar: other.progressBar, reactions: other.reactions, @@ -159,6 +188,13 @@ mixin _$StreamComponentBuilders { _other.emojiChipBar == _this.emojiChipBar && _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && + _other.messageAnnotation == _this.messageAnnotation && + _other.messageBubble == _this.messageBubble && + _other.messageContent == _this.messageContent && + _other.messageMetadata == _this.messageMetadata && + _other.messageReplies == _this.messageReplies && + _other.messageText == _this.messageText && + _other.messageWidget == _this.messageWidget && _other.onlineIndicator == _this.onlineIndicator && _other.progressBar == _this.progressBar && _other.reactions == _this.reactions; @@ -168,7 +204,7 @@ mixin _$StreamComponentBuilders { int get hashCode { final _this = (this as StreamComponentBuilders); - return Object.hash( + return Object.hashAll([ runtimeType, _this.extensions, _this.avatar, @@ -185,9 +221,16 @@ mixin _$StreamComponentBuilders { _this.emojiChipBar, _this.fileTypeIcon, _this.listTile, + _this.messageAnnotation, + _this.messageBubble, + _this.messageContent, + _this.messageMetadata, + _this.messageReplies, + _this.messageText, + _this.messageWidget, _this.onlineIndicator, _this.progressBar, _this.reactions, - ); + ]); } } diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index c4e8284..714baf8 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -12,6 +12,13 @@ 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_annotation_theme.dart'; +export 'theme/components/stream_message_bubble_theme.dart'; +export 'theme/components/stream_message_item_theme.dart'; +export 'theme/components/stream_message_metadata_theme.dart'; +export 'theme/components/stream_message_replies_theme.dart'; +export 'theme/components/stream_message_style_property.dart'; +export 'theme/components/stream_message_text_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_message_annotation_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart new file mode 100644 index 0000000..cd9881e --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart @@ -0,0 +1,120 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'stream_message_style_property.dart'; + +part 'stream_message_annotation_theme.g.theme.dart'; + +/// Visual styling properties for a message annotation row. +/// +/// Defines the appearance of annotation rows including text, icons, spacing, +/// and padding. All properties use [StreamMessageStyleProperty] for +/// placement-aware resolution. Use [StreamMessageAnnotationStyle.from] +/// for uniform values across all placements. +/// +/// {@tool snippet} +/// +/// Uniform style: +/// +/// ```dart +/// StreamMessageAnnotationStyle.from( +/// textColor: Colors.purple, +/// iconColor: Colors.purple, +/// iconSize: 18, +/// spacing: 8, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware style: +/// +/// ```dart +/// StreamMessageAnnotationStyle( +/// textColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue : Colors.grey; +/// }), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageAnnotation], which uses this styling. +@themeGen +@immutable +class StreamMessageAnnotationStyle with _$StreamMessageAnnotationStyle { + /// Creates an annotation style with optional resolver-based overrides. + const StreamMessageAnnotationStyle({ + this.textStyle, + this.textColor, + this.iconColor, + this.iconSize, + this.spacing, + this.padding, + }); + + /// A convenience constructor that constructs a + /// [StreamMessageAnnotationStyle] given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageAnnotationStyle] that doesn't override anything. + /// + /// For example, to override the default annotation text and icon colors, + /// one could write: + /// + /// ```dart + /// StreamMessageAnnotationStyle.from( + /// textColor: Colors.purple, + /// iconColor: Colors.purple, + /// ) + /// ``` + factory StreamMessageAnnotationStyle.from({ + TextStyle? textStyle, + Color? textColor, + Color? iconColor, + double? iconSize, + double? spacing, + EdgeInsetsGeometry? padding, + }) { + return StreamMessageAnnotationStyle( + textStyle: textStyle?.let(StreamMessageStyleProperty.all), + textColor: textColor?.let(StreamMessageStyleProperty.all), + iconColor: iconColor?.let(StreamMessageStyleProperty.all), + iconSize: iconSize?.let(StreamMessageStyleProperty.all), + spacing: spacing?.let(StreamMessageStyleProperty.all), + padding: padding?.let(StreamMessageStyleProperty.all), + ); + } + + /// The text style for the annotation label. + /// + /// This only controls typography. Color comes from [textColor]. + final StreamMessageStyleProperty? textStyle; + + /// The color for the annotation label text. + final StreamMessageStyleProperty? textColor; + + /// The color for the leading icon. + final StreamMessageStyleProperty? iconColor; + + /// The size for the leading icon. + final StreamMessageStyleProperty? iconSize; + + /// The gap between the leading widget and label. + final StreamMessageStyleProperty? spacing; + + /// The padding around the annotation row content. + final StreamMessageStyleProperty? padding; + + /// Linearly interpolate between two [StreamMessageAnnotationStyle] objects. + static StreamMessageAnnotationStyle? lerp( + StreamMessageAnnotationStyle? a, + StreamMessageAnnotationStyle? b, + double t, + ) => _$StreamMessageAnnotationStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart new file mode 100644 index 0000000..ff350fb --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart @@ -0,0 +1,148 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_annotation_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageAnnotationStyle { + bool get canMerge => true; + + static StreamMessageAnnotationStyle? lerp( + StreamMessageAnnotationStyle? a, + StreamMessageAnnotationStyle? 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 StreamMessageAnnotationStyle( + textStyle: StreamMessageStyleProperty.lerp( + a.textStyle, + b.textStyle, + t, + TextStyle.lerp, + ), + textColor: StreamMessageStyleProperty.lerp( + a.textColor, + b.textColor, + t, + Color.lerp, + ), + iconColor: StreamMessageStyleProperty.lerp( + a.iconColor, + b.iconColor, + t, + Color.lerp, + ), + iconSize: StreamMessageStyleProperty.lerp( + a.iconSize, + b.iconSize, + t, + lerpDouble$, + ), + spacing: StreamMessageStyleProperty.lerp( + a.spacing, + b.spacing, + t, + lerpDouble$, + ), + padding: StreamMessageStyleProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), + ); + } + + StreamMessageAnnotationStyle copyWith({ + StreamMessageStyleProperty? textStyle, + StreamMessageStyleProperty? textColor, + StreamMessageStyleProperty? iconColor, + StreamMessageStyleProperty? iconSize, + StreamMessageStyleProperty? spacing, + StreamMessageStyleProperty? padding, + }) { + final _this = (this as StreamMessageAnnotationStyle); + + return StreamMessageAnnotationStyle( + textStyle: textStyle ?? _this.textStyle, + textColor: textColor ?? _this.textColor, + iconColor: iconColor ?? _this.iconColor, + iconSize: iconSize ?? _this.iconSize, + spacing: spacing ?? _this.spacing, + padding: padding ?? _this.padding, + ); + } + + StreamMessageAnnotationStyle merge(StreamMessageAnnotationStyle? other) { + final _this = (this as StreamMessageAnnotationStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + textStyle: other.textStyle, + textColor: other.textColor, + iconColor: other.iconColor, + iconSize: other.iconSize, + spacing: other.spacing, + padding: other.padding, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageAnnotationStyle); + final _other = (other as StreamMessageAnnotationStyle); + + return _other.textStyle == _this.textStyle && + _other.textColor == _this.textColor && + _other.iconColor == _this.iconColor && + _other.iconSize == _this.iconSize && + _other.spacing == _this.spacing && + _other.padding == _this.padding; + } + + @override + int get hashCode { + final _this = (this as StreamMessageAnnotationStyle); + + return Object.hash( + runtimeType, + _this.textStyle, + _this.textColor, + _this.iconColor, + _this.iconSize, + _this.spacing, + _this.padding, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart new file mode 100644 index 0000000..9c34971 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'stream_message_style_property.dart'; + +part 'stream_message_bubble_theme.g.theme.dart'; + +/// Visual styling properties for the message bubble. +/// +/// Defines the appearance of message bubbles including shape, border, padding, +/// constraints, and background color. All properties use +/// [StreamMessageStyleProperty] for placement-aware resolution. +/// Use [StreamMessageBubbleStyle.from] for uniform values across all +/// placements. +/// +/// {@tool snippet} +/// +/// Uniform style: +/// +/// ```dart +/// StreamMessageBubbleStyle.from( +/// backgroundColor: Colors.blue, +/// padding: EdgeInsets.all(12), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware style: +/// +/// ```dart +/// StreamMessageBubbleStyle( +/// backgroundColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue.shade100 : Colors.grey.shade100; +/// }), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageBubble], which uses this styling. +@themeGen +@immutable +class StreamMessageBubbleStyle with _$StreamMessageBubbleStyle { + /// Creates a bubble style with optional resolver-based overrides. + const StreamMessageBubbleStyle({ + this.shape, + this.side, + this.padding, + this.constraints, + this.backgroundColor, + }); + + /// A convenience constructor that constructs a [StreamMessageBubbleStyle] + /// given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageBubbleStyle] that doesn't override anything. + /// + /// For example, to override the default bubble background color and + /// padding, one could write: + /// + /// ```dart + /// StreamMessageBubbleStyle.from( + /// backgroundColor: Colors.blue.shade100, + /// padding: EdgeInsets.all(12), + /// ) + /// ``` + factory StreamMessageBubbleStyle.from({ + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? padding, + BoxConstraints? constraints, + Color? backgroundColor, + }) { + return StreamMessageBubbleStyle( + shape: shape?.let(StreamMessageStyleProperty.all), + side: side?.let(StreamMessageStyleBorderSide.all), + padding: padding?.let(StreamMessageStyleProperty.all), + constraints: constraints?.let(StreamMessageStyleProperty.all), + backgroundColor: backgroundColor?.let(StreamMessageStyleProperty.all), + ); + } + + /// The shape of the bubble. + /// + /// Typically varies by stack position and alignment (tail corner side). + final StreamMessageStyleProperty? shape; + + /// The border outline of the bubble. + final StreamMessageStyleBorderSide? side; + + /// Content padding inside the bubble. + final StreamMessageStyleProperty? padding; + + /// Size constraints for the bubble. + final StreamMessageStyleProperty? constraints; + + /// The background fill color of the bubble. + /// + /// Typically differs between start-aligned and end-aligned messages. + final StreamMessageStyleProperty? backgroundColor; + + /// Linearly interpolate between two [StreamMessageBubbleStyle] objects. + static StreamMessageBubbleStyle? lerp( + StreamMessageBubbleStyle? a, + StreamMessageBubbleStyle? b, + double t, + ) => _$StreamMessageBubbleStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart new file mode 100644 index 0000000..8db26f2 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart @@ -0,0 +1,132 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_bubble_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageBubbleStyle { + bool get canMerge => true; + + static StreamMessageBubbleStyle? lerp( + StreamMessageBubbleStyle? a, + StreamMessageBubbleStyle? 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 StreamMessageBubbleStyle( + shape: StreamMessageStyleProperty.lerp( + a.shape, + b.shape, + t, + OutlinedBorder.lerp, + ), + side: StreamMessageStyleBorderSide.lerp(a.side, b.side, t), + padding: StreamMessageStyleProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), + constraints: StreamMessageStyleProperty.lerp( + a.constraints, + b.constraints, + t, + BoxConstraints.lerp, + ), + backgroundColor: StreamMessageStyleProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + ); + } + + StreamMessageBubbleStyle copyWith({ + StreamMessageStyleProperty? shape, + StreamMessageStyleBorderSide? side, + StreamMessageStyleProperty? padding, + StreamMessageStyleProperty? constraints, + StreamMessageStyleProperty? backgroundColor, + }) { + final _this = (this as StreamMessageBubbleStyle); + + return StreamMessageBubbleStyle( + shape: shape ?? _this.shape, + side: side ?? _this.side, + padding: padding ?? _this.padding, + constraints: constraints ?? _this.constraints, + backgroundColor: backgroundColor ?? _this.backgroundColor, + ); + } + + StreamMessageBubbleStyle merge(StreamMessageBubbleStyle? other) { + final _this = (this as StreamMessageBubbleStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + shape: other.shape, + side: other.side, + padding: other.padding, + constraints: other.constraints, + backgroundColor: other.backgroundColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageBubbleStyle); + final _other = (other as StreamMessageBubbleStyle); + + return _other.shape == _this.shape && + _other.side == _this.side && + _other.padding == _this.padding && + _other.constraints == _this.constraints && + _other.backgroundColor == _this.backgroundColor; + } + + @override + int get hashCode { + final _this = (this as StreamMessageBubbleStyle); + + return Object.hash( + runtimeType, + _this.shape, + _this.side, + _this.padding, + _this.constraints, + _this.backgroundColor, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart new file mode 100644 index 0000000..10b4964 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; +import 'stream_avatar_theme.dart'; +import 'stream_message_annotation_theme.dart'; +import 'stream_message_bubble_theme.dart'; +import 'stream_message_metadata_theme.dart'; +import 'stream_message_replies_theme.dart'; +import 'stream_message_style_property.dart'; +import 'stream_message_text_theme.dart'; + +part 'stream_message_item_theme.g.theme.dart'; + +/// Applies a message item theme to descendant message widgets. +/// +/// Wrap a subtree with [StreamMessageItemTheme] to override placement-aware +/// styling for message sub-components (bubble, annotation, metadata, replies). +/// +/// {@tool snippet} +/// +/// Override bubble colors based on placement: +/// +/// ```dart +/// StreamMessageItemTheme( +/// data: StreamMessageItemThemeData( +/// backgroundColor: Colors.blue.shade50, +/// bubble: StreamMessageBubbleStyle( +/// backgroundColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue.shade100 : Colors.grey.shade100; +/// }), +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which describes the theme data. +class StreamMessageItemTheme extends InheritedTheme { + /// Creates a message item theme that controls descendant message widgets. + const StreamMessageItemTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The message item theme data for descendant widgets. + final StreamMessageItemThemeData data; + + /// Returns the [StreamMessageItemThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. + static StreamMessageItemThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageItemTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageItemTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageItemTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing message item appearance and sub-components. +/// +/// Properties are organized in two groups: +/// +/// 1. **Item-level** — visual and layout properties for the message item +/// itself ([backgroundColor], [padding], [spacing], [avatarSize]). +/// 2. **Sub-component styles** — grouped style overrides for each child +/// component ([text], [bubble], [annotation], [metadata], [replies]). +/// +/// A `null` field means "use defaults." +/// +/// See also: +/// +/// * [StreamMessageItemTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamMessageItemThemeData with _$StreamMessageItemThemeData { + /// Creates message item theme data with optional overrides. + const StreamMessageItemThemeData({ + this.backgroundColor, + this.leadingVisibility, + this.padding, + this.spacing, + this.avatarSize, + this.text, + this.bubble, + this.annotation, + this.metadata, + this.replies, + }); + + /// Background color for the entire message item row. + /// + /// Typically used for state-driven styling (e.g. selected, reminder). + /// When null, the message item has no background. + final Color? backgroundColor; + + /// Controls the visibility of the leading widget based on placement. + /// + /// This resolves a [StreamVisibility] value from the current + /// [StreamMessagePlacementData], allowing visibility to vary by stack + /// position (e.g. only show the avatar on the bottom message of a stack). + /// + /// When null, the leading widget defaults to [StreamVisibility.visible]. + final StreamMessageStyleVisibility? leadingVisibility; + + /// Outer padding around the entire message item. + final EdgeInsetsGeometry? padding; + + /// Horizontal spacing between the leading avatar and the content. + final double? spacing; + + /// Default size for the leading avatar. + /// + /// When non-null, descendant avatars inherit this size override. + /// When null, avatars use the size from the nearest ancestor + /// avatar theme or their own default. + final StreamAvatarSize? avatarSize; + + /// Style overrides for the message text (markdown). + final StreamMessageTextStyle? text; + + /// Style overrides for the message bubble. + final StreamMessageBubbleStyle? bubble; + + /// Style overrides for the message annotation. + final StreamMessageAnnotationStyle? annotation; + + /// Style overrides for the message metadata. + final StreamMessageMetadataStyle? metadata; + + /// Style overrides for the message replies. + final StreamMessageRepliesStyle? replies; + + /// Linearly interpolate between two [StreamMessageItemThemeData] objects. + static StreamMessageItemThemeData? lerp( + StreamMessageItemThemeData? a, + StreamMessageItemThemeData? b, + double t, + ) => _$StreamMessageItemThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart new file mode 100644 index 0000000..8974b65 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart @@ -0,0 +1,150 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_item_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageItemThemeData { + bool get canMerge => true; + + static StreamMessageItemThemeData? lerp( + StreamMessageItemThemeData? a, + StreamMessageItemThemeData? 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 StreamMessageItemThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + leadingVisibility: StreamMessageStyleVisibility.lerp( + a.leadingVisibility, + b.leadingVisibility, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + avatarSize: t < 0.5 ? a.avatarSize : b.avatarSize, + text: StreamMessageTextStyle.lerp(a.text, b.text, t), + bubble: StreamMessageBubbleStyle.lerp(a.bubble, b.bubble, t), + annotation: StreamMessageAnnotationStyle.lerp( + a.annotation, + b.annotation, + t, + ), + metadata: StreamMessageMetadataStyle.lerp(a.metadata, b.metadata, t), + replies: StreamMessageRepliesStyle.lerp(a.replies, b.replies, t), + ); + } + + StreamMessageItemThemeData copyWith({ + Color? backgroundColor, + StreamMessageStyleVisibility? leadingVisibility, + EdgeInsetsGeometry? padding, + double? spacing, + StreamAvatarSize? avatarSize, + StreamMessageTextStyle? text, + StreamMessageBubbleStyle? bubble, + StreamMessageAnnotationStyle? annotation, + StreamMessageMetadataStyle? metadata, + StreamMessageRepliesStyle? replies, + }) { + final _this = (this as StreamMessageItemThemeData); + + return StreamMessageItemThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + leadingVisibility: leadingVisibility ?? _this.leadingVisibility, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + avatarSize: avatarSize ?? _this.avatarSize, + text: text ?? _this.text, + bubble: bubble ?? _this.bubble, + annotation: annotation ?? _this.annotation, + metadata: metadata ?? _this.metadata, + replies: replies ?? _this.replies, + ); + } + + StreamMessageItemThemeData merge(StreamMessageItemThemeData? other) { + final _this = (this as StreamMessageItemThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + leadingVisibility: other.leadingVisibility, + padding: other.padding, + spacing: other.spacing, + avatarSize: other.avatarSize, + text: _this.text?.merge(other.text) ?? other.text, + bubble: _this.bubble?.merge(other.bubble) ?? other.bubble, + annotation: _this.annotation?.merge(other.annotation) ?? other.annotation, + metadata: _this.metadata?.merge(other.metadata) ?? other.metadata, + replies: _this.replies?.merge(other.replies) ?? other.replies, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageItemThemeData); + final _other = (other as StreamMessageItemThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.leadingVisibility == _this.leadingVisibility && + _other.padding == _this.padding && + _other.spacing == _this.spacing && + _other.avatarSize == _this.avatarSize && + _other.text == _this.text && + _other.bubble == _this.bubble && + _other.annotation == _this.annotation && + _other.metadata == _this.metadata && + _other.replies == _this.replies; + } + + @override + int get hashCode { + final _this = (this as StreamMessageItemThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.leadingVisibility, + _this.padding, + _this.spacing, + _this.avatarSize, + _this.text, + _this.bubble, + _this.annotation, + _this.metadata, + _this.replies, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart new file mode 100644 index 0000000..33d56db --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart @@ -0,0 +1,148 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'stream_message_style_property.dart'; + +part 'stream_message_metadata_theme.g.theme.dart'; + +/// Visual styling properties for a message metadata row. +/// +/// Defines the appearance of metadata rows including username, timestamp, +/// edited indicator, status icon, and spacing. All properties use +/// [StreamMessageStyleProperty] for placement-aware resolution. +/// Use [StreamMessageMetadataStyle.from] for uniform values across all +/// placements. +/// +/// {@tool snippet} +/// +/// Uniform style: +/// +/// ```dart +/// StreamMessageMetadataStyle.from( +/// usernameColor: Colors.blue, +/// timestampColor: Colors.grey, +/// spacing: 12, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware style: +/// +/// ```dart +/// StreamMessageMetadataStyle( +/// usernameColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue : Colors.grey; +/// }), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageMetadata], which uses this styling. +@themeGen +@immutable +class StreamMessageMetadataStyle with _$StreamMessageMetadataStyle { + /// Creates a metadata style with optional resolver-based overrides. + const StreamMessageMetadataStyle({ + this.usernameTextStyle, + this.usernameColor, + this.timestampTextStyle, + this.timestampColor, + this.editedTextStyle, + this.editedColor, + this.statusColor, + this.statusIconSize, + this.spacing, + this.statusSpacing, + this.minHeight, + }); + + /// A convenience constructor that constructs a + /// [StreamMessageMetadataStyle] given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageMetadataStyle] that doesn't override anything. + /// + /// For example, to override the default username and timestamp colors, + /// one could write: + /// + /// ```dart + /// StreamMessageMetadataStyle.from( + /// usernameColor: Colors.blue, + /// timestampColor: Colors.grey, + /// ) + /// ``` + factory StreamMessageMetadataStyle.from({ + TextStyle? usernameTextStyle, + Color? usernameColor, + TextStyle? timestampTextStyle, + Color? timestampColor, + TextStyle? editedTextStyle, + Color? editedColor, + Color? statusColor, + double? statusIconSize, + double? spacing, + double? statusSpacing, + double? minHeight, + }) { + return StreamMessageMetadataStyle( + usernameTextStyle: usernameTextStyle?.let(StreamMessageStyleProperty.all), + usernameColor: usernameColor?.let(StreamMessageStyleProperty.all), + timestampTextStyle: timestampTextStyle?.let(StreamMessageStyleProperty.all), + timestampColor: timestampColor?.let(StreamMessageStyleProperty.all), + editedTextStyle: editedTextStyle?.let(StreamMessageStyleProperty.all), + editedColor: editedColor?.let(StreamMessageStyleProperty.all), + statusColor: statusColor?.let(StreamMessageStyleProperty.all), + statusIconSize: statusIconSize?.let(StreamMessageStyleProperty.all), + spacing: spacing?.let(StreamMessageStyleProperty.all), + statusSpacing: statusSpacing?.let(StreamMessageStyleProperty.all), + minHeight: minHeight?.let(StreamMessageStyleProperty.all), + ); + } + + /// The text style for the username. + final StreamMessageStyleProperty? usernameTextStyle; + + /// The color for the username text. + final StreamMessageStyleProperty? usernameColor; + + /// The text style for the timestamp. + final StreamMessageStyleProperty? timestampTextStyle; + + /// The color for the timestamp text. + final StreamMessageStyleProperty? timestampColor; + + /// The text style for the edited indicator. + final StreamMessageStyleProperty? editedTextStyle; + + /// The color for the edited indicator text. + final StreamMessageStyleProperty? editedColor; + + /// The color for the status icon. + final StreamMessageStyleProperty? statusColor; + + /// The size for the status icon. + final StreamMessageStyleProperty? statusIconSize; + + /// The gap between main elements (username, timestamp group, edited). + final StreamMessageStyleProperty? spacing; + + /// The gap between the status icon and the timestamp. + final StreamMessageStyleProperty? statusSpacing; + + /// The minimum height of the metadata row. + final StreamMessageStyleProperty? minHeight; + + /// Linearly interpolate between two [StreamMessageMetadataStyle] objects. + static StreamMessageMetadataStyle? lerp( + StreamMessageMetadataStyle? a, + StreamMessageMetadataStyle? b, + double t, + ) => _$StreamMessageMetadataStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart new file mode 100644 index 0000000..2cc8ebe --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart @@ -0,0 +1,203 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_metadata_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageMetadataStyle { + bool get canMerge => true; + + static StreamMessageMetadataStyle? lerp( + StreamMessageMetadataStyle? a, + StreamMessageMetadataStyle? 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 StreamMessageMetadataStyle( + usernameTextStyle: StreamMessageStyleProperty.lerp( + a.usernameTextStyle, + b.usernameTextStyle, + t, + TextStyle.lerp, + ), + usernameColor: StreamMessageStyleProperty.lerp( + a.usernameColor, + b.usernameColor, + t, + Color.lerp, + ), + timestampTextStyle: StreamMessageStyleProperty.lerp( + a.timestampTextStyle, + b.timestampTextStyle, + t, + TextStyle.lerp, + ), + timestampColor: StreamMessageStyleProperty.lerp( + a.timestampColor, + b.timestampColor, + t, + Color.lerp, + ), + editedTextStyle: StreamMessageStyleProperty.lerp( + a.editedTextStyle, + b.editedTextStyle, + t, + TextStyle.lerp, + ), + editedColor: StreamMessageStyleProperty.lerp( + a.editedColor, + b.editedColor, + t, + Color.lerp, + ), + statusColor: StreamMessageStyleProperty.lerp( + a.statusColor, + b.statusColor, + t, + Color.lerp, + ), + statusIconSize: StreamMessageStyleProperty.lerp( + a.statusIconSize, + b.statusIconSize, + t, + lerpDouble$, + ), + spacing: StreamMessageStyleProperty.lerp( + a.spacing, + b.spacing, + t, + lerpDouble$, + ), + statusSpacing: StreamMessageStyleProperty.lerp( + a.statusSpacing, + b.statusSpacing, + t, + lerpDouble$, + ), + minHeight: StreamMessageStyleProperty.lerp( + a.minHeight, + b.minHeight, + t, + lerpDouble$, + ), + ); + } + + StreamMessageMetadataStyle copyWith({ + StreamMessageStyleProperty? usernameTextStyle, + StreamMessageStyleProperty? usernameColor, + StreamMessageStyleProperty? timestampTextStyle, + StreamMessageStyleProperty? timestampColor, + StreamMessageStyleProperty? editedTextStyle, + StreamMessageStyleProperty? editedColor, + StreamMessageStyleProperty? statusColor, + StreamMessageStyleProperty? statusIconSize, + StreamMessageStyleProperty? spacing, + StreamMessageStyleProperty? statusSpacing, + StreamMessageStyleProperty? minHeight, + }) { + final _this = (this as StreamMessageMetadataStyle); + + return StreamMessageMetadataStyle( + usernameTextStyle: usernameTextStyle ?? _this.usernameTextStyle, + usernameColor: usernameColor ?? _this.usernameColor, + timestampTextStyle: timestampTextStyle ?? _this.timestampTextStyle, + timestampColor: timestampColor ?? _this.timestampColor, + editedTextStyle: editedTextStyle ?? _this.editedTextStyle, + editedColor: editedColor ?? _this.editedColor, + statusColor: statusColor ?? _this.statusColor, + statusIconSize: statusIconSize ?? _this.statusIconSize, + spacing: spacing ?? _this.spacing, + statusSpacing: statusSpacing ?? _this.statusSpacing, + minHeight: minHeight ?? _this.minHeight, + ); + } + + StreamMessageMetadataStyle merge(StreamMessageMetadataStyle? other) { + final _this = (this as StreamMessageMetadataStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + usernameTextStyle: other.usernameTextStyle, + usernameColor: other.usernameColor, + timestampTextStyle: other.timestampTextStyle, + timestampColor: other.timestampColor, + editedTextStyle: other.editedTextStyle, + editedColor: other.editedColor, + statusColor: other.statusColor, + statusIconSize: other.statusIconSize, + spacing: other.spacing, + statusSpacing: other.statusSpacing, + minHeight: other.minHeight, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageMetadataStyle); + final _other = (other as StreamMessageMetadataStyle); + + return _other.usernameTextStyle == _this.usernameTextStyle && + _other.usernameColor == _this.usernameColor && + _other.timestampTextStyle == _this.timestampTextStyle && + _other.timestampColor == _this.timestampColor && + _other.editedTextStyle == _this.editedTextStyle && + _other.editedColor == _this.editedColor && + _other.statusColor == _this.statusColor && + _other.statusIconSize == _this.statusIconSize && + _other.spacing == _this.spacing && + _other.statusSpacing == _this.statusSpacing && + _other.minHeight == _this.minHeight; + } + + @override + int get hashCode { + final _this = (this as StreamMessageMetadataStyle); + + return Object.hash( + runtimeType, + _this.usernameTextStyle, + _this.usernameColor, + _this.timestampTextStyle, + _this.timestampColor, + _this.editedTextStyle, + _this.editedColor, + _this.statusColor, + _this.statusIconSize, + _this.spacing, + _this.statusSpacing, + _this.minHeight, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart new file mode 100644 index 0000000..bed87bd --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart @@ -0,0 +1,124 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'stream_message_style_property.dart'; + +part 'stream_message_replies_theme.g.theme.dart'; + +/// Visual styling properties for a message replies row. +/// +/// Defines the appearance of replies rows including label, spacing, padding, +/// and connector styling. All properties use [StreamMessageStyleProperty] +/// for placement-aware resolution. Use [StreamMessageRepliesStyle.from] +/// for uniform values across all placements. +/// +/// {@tool snippet} +/// +/// Uniform style: +/// +/// ```dart +/// StreamMessageRepliesStyle.from( +/// labelColor: Colors.blue, +/// spacing: 12, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware style: +/// +/// ```dart +/// StreamMessageRepliesStyle( +/// labelColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue : Colors.purple; +/// }), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageReplies], which uses this styling. +@themeGen +@immutable +class StreamMessageRepliesStyle with _$StreamMessageRepliesStyle { + /// Creates a replies style with optional resolver-based overrides. + const StreamMessageRepliesStyle({ + this.labelTextStyle, + this.labelColor, + this.spacing, + this.padding, + this.connectorColor, + this.connectorStrokeWidth, + this.clipBehavior, + }); + + /// A convenience constructor that constructs a + /// [StreamMessageRepliesStyle] given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageRepliesStyle] that doesn't override anything. + /// + /// For example, to override the default replies label color and spacing, + /// one could write: + /// + /// ```dart + /// StreamMessageRepliesStyle.from( + /// labelColor: Colors.blue, + /// spacing: 12, + /// ) + /// ``` + factory StreamMessageRepliesStyle.from({ + TextStyle? labelTextStyle, + Color? labelColor, + double? spacing, + EdgeInsetsGeometry? padding, + Color? connectorColor, + double? connectorStrokeWidth, + Clip? clipBehavior, + }) { + return StreamMessageRepliesStyle( + labelTextStyle: labelTextStyle?.let(StreamMessageStyleProperty.all), + labelColor: labelColor?.let(StreamMessageStyleProperty.all), + spacing: spacing?.let(StreamMessageStyleProperty.all), + padding: padding?.let(StreamMessageStyleProperty.all), + connectorColor: connectorColor?.let(StreamMessageStyleProperty.all), + connectorStrokeWidth: connectorStrokeWidth?.let(StreamMessageStyleProperty.all), + clipBehavior: clipBehavior?.let(StreamMessageStyleClip.all), + ); + } + + /// The text style for the replies label. + final StreamMessageStyleProperty? labelTextStyle; + + /// The color for the replies label text. + final StreamMessageStyleProperty? labelColor; + + /// The gap between elements (connector, avatars, label). + final StreamMessageStyleProperty? spacing; + + /// The padding around the replies row content. + final StreamMessageStyleProperty? padding; + + /// The color of the connector path linking the row to the message bubble. + final StreamMessageStyleProperty? connectorColor; + + /// The stroke width of the connector path. + final StreamMessageStyleProperty? connectorStrokeWidth; + + /// How to clip the widget's content. + /// + /// Controls whether the connector overflow is clipped at the row boundary. + final StreamMessageStyleClip? clipBehavior; + + /// Linearly interpolate between two [StreamMessageRepliesStyle] objects. + static StreamMessageRepliesStyle? lerp( + StreamMessageRepliesStyle? a, + StreamMessageRepliesStyle? b, + double t, + ) => _$StreamMessageRepliesStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart new file mode 100644 index 0000000..c606f79 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart @@ -0,0 +1,158 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_replies_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageRepliesStyle { + bool get canMerge => true; + + static StreamMessageRepliesStyle? lerp( + StreamMessageRepliesStyle? a, + StreamMessageRepliesStyle? 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 StreamMessageRepliesStyle( + labelTextStyle: StreamMessageStyleProperty.lerp( + a.labelTextStyle, + b.labelTextStyle, + t, + TextStyle.lerp, + ), + labelColor: StreamMessageStyleProperty.lerp( + a.labelColor, + b.labelColor, + t, + Color.lerp, + ), + spacing: StreamMessageStyleProperty.lerp( + a.spacing, + b.spacing, + t, + lerpDouble$, + ), + padding: StreamMessageStyleProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), + connectorColor: StreamMessageStyleProperty.lerp( + a.connectorColor, + b.connectorColor, + t, + Color.lerp, + ), + connectorStrokeWidth: StreamMessageStyleProperty.lerp( + a.connectorStrokeWidth, + b.connectorStrokeWidth, + t, + lerpDouble$, + ), + clipBehavior: StreamMessageStyleClip.lerp( + a.clipBehavior, + b.clipBehavior, + t, + ), + ); + } + + StreamMessageRepliesStyle copyWith({ + StreamMessageStyleProperty? labelTextStyle, + StreamMessageStyleProperty? labelColor, + StreamMessageStyleProperty? spacing, + StreamMessageStyleProperty? padding, + StreamMessageStyleProperty? connectorColor, + StreamMessageStyleProperty? connectorStrokeWidth, + StreamMessageStyleClip? clipBehavior, + }) { + final _this = (this as StreamMessageRepliesStyle); + + return StreamMessageRepliesStyle( + labelTextStyle: labelTextStyle ?? _this.labelTextStyle, + labelColor: labelColor ?? _this.labelColor, + spacing: spacing ?? _this.spacing, + padding: padding ?? _this.padding, + connectorColor: connectorColor ?? _this.connectorColor, + connectorStrokeWidth: connectorStrokeWidth ?? _this.connectorStrokeWidth, + clipBehavior: clipBehavior ?? _this.clipBehavior, + ); + } + + StreamMessageRepliesStyle merge(StreamMessageRepliesStyle? other) { + final _this = (this as StreamMessageRepliesStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + labelTextStyle: other.labelTextStyle, + labelColor: other.labelColor, + spacing: other.spacing, + padding: other.padding, + connectorColor: other.connectorColor, + connectorStrokeWidth: other.connectorStrokeWidth, + clipBehavior: other.clipBehavior, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageRepliesStyle); + final _other = (other as StreamMessageRepliesStyle); + + return _other.labelTextStyle == _this.labelTextStyle && + _other.labelColor == _this.labelColor && + _other.spacing == _this.spacing && + _other.padding == _this.padding && + _other.connectorColor == _this.connectorColor && + _other.connectorStrokeWidth == _this.connectorStrokeWidth && + _other.clipBehavior == _this.clipBehavior; + } + + @override + int get hashCode { + final _this = (this as StreamMessageRepliesStyle); + + return Object.hash( + runtimeType, + _this.labelTextStyle, + _this.labelColor, + _this.spacing, + _this.padding, + _this.connectorColor, + _this.connectorStrokeWidth, + _this.clipBehavior, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart new file mode 100644 index 0000000..02f6226 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart @@ -0,0 +1,358 @@ +import 'dart:ui' show Clip; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart' show BorderSide; + +import '../../components/common/stream_visibility.dart'; +import '../../components/message_placement/stream_message_placement.dart'; + +/// Resolves a value of type [T] based on a message's [StreamMessagePlacementData]. +/// +/// Each field in a message style class (e.g. `StreamMessageBubbleStyle.shape`) +/// is a [StreamMessageStyleProperty] so that its value can vary by alignment +/// (start / end) and stack position (single / top / middle / bottom). +/// +/// Use the factory constructors for common patterns: +/// +/// {@tool snippet} +/// +/// Uniform value for all placements: +/// +/// ```dart +/// StreamMessageStyleProperty.all(EdgeInsets.all(12)) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Alignment-aware color: +/// +/// ```dart +/// StreamMessageStyleProperty.resolveWith((placement) { +/// final isEnd = placement.alignment == StreamMessageAlignment.end; +/// return isEnd ? brandColor : surfaceColor; +/// }) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Position + alignment aware shape: +/// +/// ```dart +/// StreamMessageStyleProperty.resolveWith((placement) { +/// final isEnd = placement.alignment == StreamMessageAlignment.end; +/// return switch (placement.stackPosition) { +/// StreamMessageStackPosition.single => +/// RoundedRectangleBorder(borderRadius: BorderRadius.all(r.xxl)), +/// StreamMessageStackPosition.top => +/// RoundedRectangleBorder(borderRadius: BorderRadiusDirectional.only( +/// topStart: r.xxl, +/// topEnd: r.xxl, +/// bottomStart: isEnd ? r.xxl : tailRadius, +/// bottomEnd: isEnd ? tailRadius : r.xxl, +/// )), +/// _ => defaultShape, +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessagePlacementData], the context passed to [resolve]. +abstract class StreamMessageStyleProperty { + /// Const constructor for subclasses. + const StreamMessageStyleProperty(); + + /// Resolves this property for the given [placement]. + T resolve(StreamMessagePlacementData placement); + + /// Creates a property that returns [value] for every placement. + static StreamMessageStyleProperty all(T value) => _AllProperty(value); + + /// Creates a property that delegates to [callback] for resolution. + static StreamMessageStyleProperty resolveWith( + T Function(StreamMessagePlacementData placement) callback, + ) => _ResolveWithProperty(callback); + + /// Linearly interpolates between two [StreamMessageStyleProperty] values. + static StreamMessageStyleProperty? lerp( + StreamMessageStyleProperty? a, + StreamMessageStyleProperty? b, + double t, + T? Function(T?, T?, double) lerpFunction, + ) { + if (a == null && b == null) return null; + return _LerpProperty(a, b, t, lerpFunction); + } +} + +@immutable +class _AllProperty extends StreamMessageStyleProperty { + const _AllProperty(this._value); + + final T _value; + + @override + T resolve(StreamMessagePlacementData placement) => _value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _AllProperty && other._value == _value; + } + + @override + int get hashCode => _value.hashCode; +} + +class _ResolveWithProperty extends StreamMessageStyleProperty { + const _ResolveWithProperty(this._callback); + + final T Function(StreamMessagePlacementData placement) _callback; + + @override + T resolve(StreamMessagePlacementData placement) => _callback(placement); +} + +class _LerpProperty extends StreamMessageStyleProperty { + const _LerpProperty(this._a, this._b, this._t, this._lerpFunction); + + final StreamMessageStyleProperty? _a; + final StreamMessageStyleProperty? _b; + final double _t; + final T? Function(T?, T?, double) _lerpFunction; + + @override + T? resolve(StreamMessagePlacementData placement) { + return _lerpFunction(_a?.resolve(placement), _b?.resolve(placement), _t); + } +} + +/// A [Clip] value that can depend on [StreamMessagePlacementData]. +/// +/// {@tool snippet} +/// +/// Same clip for all placements: +/// +/// ```dart +/// StreamMessageStyleClip.all(Clip.hardEdge) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware clip: +/// +/// ```dart +/// StreamMessageStyleClip.resolveWith((placement) { +/// return switch (placement.stackPosition) { +/// .top || .middle => Clip.none, +/// .bottom || .single => Clip.hardEdge, +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the generic placement-aware resolver. +/// * [StreamMessagePlacementData], the context passed to [resolve]. +extension type const StreamMessageStyleClip._(StreamMessageStyleProperty _property) + implements StreamMessageStyleProperty { + /// Creates a clip that returns [clip] for every placement. + static StreamMessageStyleClip all(Clip clip) => ._(.all(clip)); + + /// Creates a clip that delegates to [callback] for resolution. + static StreamMessageStyleClip resolveWith( + Clip? Function(StreamMessagePlacementData placement) callback, + ) => ._(.resolveWith(callback)); + + /// Linearly interpolate between two [StreamMessageStyleClip] values. + static StreamMessageStyleClip? lerp( + StreamMessageStyleClip? a, + StreamMessageStyleClip? b, + double t, + ) { + if (a == null && b == null) return null; + if (identical(a, b)) return a; + return t < 0.5 ? a : b; + } +} + +/// A [StreamVisibility] value that can depend on [StreamMessagePlacementData]. +/// +/// {@tool snippet} +/// +/// Same visibility for all placements: +/// +/// ```dart +/// StreamMessageStyleVisibility.all(StreamVisibility.hidden) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware visibility: +/// +/// ```dart +/// StreamMessageStyleVisibility.resolveWith((placement) { +/// return switch (placement.stackPosition) { +/// .top || .middle => StreamVisibility.hidden, +/// .bottom || .single => StreamVisibility.visible, +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the generic placement-aware resolver. +/// * [StreamMessagePlacementData], the context passed to [resolve]. +extension type const StreamMessageStyleVisibility._(StreamMessageStyleProperty _property) + implements StreamMessageStyleProperty { + /// Creates a visibility that returns [visibility] for every placement. + static StreamMessageStyleVisibility all(StreamVisibility visibility) => ._(.all(visibility)); + + /// Creates a visibility that delegates to [callback] for resolution. + static StreamMessageStyleVisibility resolveWith( + StreamVisibility? Function(StreamMessagePlacementData placement) callback, + ) => ._(.resolveWith(callback)); + + /// Linearly interpolate between two [StreamMessageStyleVisibility] values. + static StreamMessageStyleVisibility? lerp( + StreamMessageStyleVisibility? a, + StreamMessageStyleVisibility? b, + double t, + ) { + if (a == null && b == null) return null; + if (identical(a, b)) return a; + return t < 0.5 ? a : b; + } +} + +/// A [BorderSide] value that can depend on [StreamMessagePlacementData]. +/// +/// {@tool snippet} +/// +/// Same border for all placements: +/// +/// ```dart +/// StreamMessageStyleBorderSide.all(BorderSide(color: Colors.grey)) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware border: +/// +/// ```dart +/// StreamMessageStyleBorderSide.resolveWith((placement) { +/// return switch (placement.alignment) { +/// .start => BorderSide(color: Colors.grey), +/// .end => BorderSide(color: Colors.blue), +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the generic placement-aware resolver. +/// * [StreamMessagePlacementData], the context passed to [resolve]. +extension type const StreamMessageStyleBorderSide._(StreamMessageStyleProperty _property) + implements StreamMessageStyleProperty { + /// Creates a border side that returns [side] for every placement. + static StreamMessageStyleBorderSide all(BorderSide side) => ._(.all(side)); + + /// Creates a border side that delegates to [callback] for resolution. + static StreamMessageStyleBorderSide resolveWith( + BorderSide? Function(StreamMessagePlacementData placement) callback, + ) => ._(.resolveWith(callback)); + + /// Linearly interpolate between two [StreamMessageStyleBorderSide] values. + static StreamMessageStyleBorderSide? lerp( + StreamMessageStyleBorderSide? a, + StreamMessageStyleBorderSide? b, + double t, + ) { + if (a == null && b == null) return null; + if (identical(a, b)) return a; + return ._(_LerpBorderSide(a, b, t)); + } +} + +class _LerpBorderSide extends StreamMessageStyleProperty { + const _LerpBorderSide(this._a, this._b, this._t); + + final StreamMessageStyleBorderSide? _a; + final StreamMessageStyleBorderSide? _b; + final double _t; + + @override + BorderSide? resolve(StreamMessagePlacementData placement) { + final resolvedA = _a?.resolve(placement); + final resolvedB = _b?.resolve(placement); + if (resolvedA == null && resolvedB == null) return null; + if (resolvedA == null) { + return BorderSide.lerp( + BorderSide(width: 0, color: resolvedB!.color.withAlpha(0)), + resolvedB, + _t, + ); + } + if (resolvedB == null) { + return BorderSide.lerp( + resolvedA, + BorderSide(width: 0, color: resolvedA.color.withAlpha(0)), + _t, + ); + } + return BorderSide.lerp(resolvedA, resolvedB, _t); + } +} + +/// Resolves style properties through a cascade of style sources for a given +/// [StreamMessagePlacementData]. +/// +/// Given an ordered list of style sources, returns the first non-null +/// resolved value for a requested property. +/// +/// {@tool snippet} +/// +/// ```dart +/// final resolve = StreamMessageStyleResolver( +/// placement, +/// [widgetStyle, themeStyle, defaults], +/// ); +/// +/// final color = resolve((s) => s?.backgroundColor); +/// final padding = resolve((s) => s?.padding); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the resolver type for each field. +/// * [StreamMessagePlacementData], the context passed to each resolver. +class StreamMessageStyleResolver { + /// Creates a resolver that cascades through [styles] in order. + const StreamMessageStyleResolver(this._placement, this._styles); + + final StreamMessagePlacementData _placement; + final List _styles; + + /// Resolves the first non-null value for [getProperty] across all style + /// sources. + /// + /// The last entry in [_styles] should be a defaults object that provides + /// every property, ensuring this method always returns a value. + T call(StreamMessageStyleProperty? Function(S? style) getProperty) { + for (final style in _styles) { + final resolved = getProperty(style)?.resolve(_placement); + if (resolved != null) return resolved; + } + throw StateError('No style source provided a value for the requested property'); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart new file mode 100644 index 0000000..2b4d0ce --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart @@ -0,0 +1,135 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'stream_message_style_property.dart'; + +part 'stream_message_text_theme.g.theme.dart'; + +/// Placement-aware styling for markdown message text. +/// +/// Controls the appearance of paragraph text, links, and mentions. +/// Use [StreamMessageTextStyle.from] for uniform values across all placements. +/// +/// Additional markdown styles (headings, code blocks, blockquotes, tables, +/// layout) can be customised via [StreamMessageTextProps.styleSheet]. +/// +/// {@tool snippet} +/// +/// Uniform style: +/// +/// ```dart +/// StreamMessageTextStyle.from( +/// textColor: Colors.black, +/// linkStyle: TextStyle(color: Colors.blue, decoration: TextDecoration.underline), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware style: +/// +/// ```dart +/// StreamMessageTextStyle( +/// textColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.white : Colors.black; +/// }), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageText], which uses this styling. +@themeGen +@immutable +class StreamMessageTextStyle with _$StreamMessageTextStyle { + /// Creates a message text style with optional resolver-based overrides. + const StreamMessageTextStyle({ + this.textStyle, + this.textColor, + this.linkStyle, + this.linkColor, + this.mentionStyle, + this.mentionColor, + this.singleEmojiStyle, + this.doubleEmojiStyle, + this.tripleEmojiStyle, + }); + + /// A convenience constructor that constructs a [StreamMessageTextStyle] + /// given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageTextStyle] that doesn't override anything. + /// + /// For example, to override the default text color and link style, one + /// could write: + /// + /// ```dart + /// StreamMessageTextStyle.from( + /// textColor: Colors.black, + /// linkStyle: TextStyle(color: Colors.blue), + /// ) + /// ``` + factory StreamMessageTextStyle.from({ + TextStyle? textStyle, + Color? textColor, + TextStyle? linkStyle, + Color? linkColor, + TextStyle? mentionStyle, + Color? mentionColor, + TextStyle? singleEmojiStyle, + TextStyle? doubleEmojiStyle, + TextStyle? tripleEmojiStyle, + }) { + return StreamMessageTextStyle( + textStyle: textStyle?.let(StreamMessageStyleProperty.all), + textColor: textColor?.let(StreamMessageStyleProperty.all), + linkStyle: linkStyle?.let(StreamMessageStyleProperty.all), + linkColor: linkColor?.let(StreamMessageStyleProperty.all), + mentionStyle: mentionStyle?.let(StreamMessageStyleProperty.all), + mentionColor: mentionColor?.let(StreamMessageStyleProperty.all), + singleEmojiStyle: singleEmojiStyle?.let(StreamMessageStyleProperty.all), + doubleEmojiStyle: doubleEmojiStyle?.let(StreamMessageStyleProperty.all), + tripleEmojiStyle: tripleEmojiStyle?.let(StreamMessageStyleProperty.all), + ); + } + + /// The base text style for paragraph content. + final StreamMessageStyleProperty? textStyle; + + /// The color for paragraph text. + final StreamMessageStyleProperty? textColor; + + /// The text style for links. + final StreamMessageStyleProperty? linkStyle; + + /// The color for link text. + final StreamMessageStyleProperty? linkColor; + + /// The text style for @mention text. + final StreamMessageStyleProperty? mentionStyle; + + /// The color for @mention text. + final StreamMessageStyleProperty? mentionColor; + + /// The text style for emoji-only messages containing exactly one emoji. + final StreamMessageStyleProperty? singleEmojiStyle; + + /// The text style for emoji-only messages containing exactly two emojis. + final StreamMessageStyleProperty? doubleEmojiStyle; + + /// The text style for emoji-only messages containing exactly three emojis. + final StreamMessageStyleProperty? tripleEmojiStyle; + + /// Linearly interpolate between two [StreamMessageTextStyle] objects. + static StreamMessageTextStyle? lerp( + StreamMessageTextStyle? a, + StreamMessageTextStyle? b, + double t, + ) => _$StreamMessageTextStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart new file mode 100644 index 0000000..80840ff --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart @@ -0,0 +1,181 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_text_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageTextStyle { + bool get canMerge => true; + + static StreamMessageTextStyle? lerp( + StreamMessageTextStyle? a, + StreamMessageTextStyle? 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 StreamMessageTextStyle( + textStyle: StreamMessageStyleProperty.lerp( + a.textStyle, + b.textStyle, + t, + TextStyle.lerp, + ), + textColor: StreamMessageStyleProperty.lerp( + a.textColor, + b.textColor, + t, + Color.lerp, + ), + linkStyle: StreamMessageStyleProperty.lerp( + a.linkStyle, + b.linkStyle, + t, + TextStyle.lerp, + ), + linkColor: StreamMessageStyleProperty.lerp( + a.linkColor, + b.linkColor, + t, + Color.lerp, + ), + mentionStyle: StreamMessageStyleProperty.lerp( + a.mentionStyle, + b.mentionStyle, + t, + TextStyle.lerp, + ), + mentionColor: StreamMessageStyleProperty.lerp( + a.mentionColor, + b.mentionColor, + t, + Color.lerp, + ), + singleEmojiStyle: StreamMessageStyleProperty.lerp( + a.singleEmojiStyle, + b.singleEmojiStyle, + t, + TextStyle.lerp, + ), + doubleEmojiStyle: StreamMessageStyleProperty.lerp( + a.doubleEmojiStyle, + b.doubleEmojiStyle, + t, + TextStyle.lerp, + ), + tripleEmojiStyle: StreamMessageStyleProperty.lerp( + a.tripleEmojiStyle, + b.tripleEmojiStyle, + t, + TextStyle.lerp, + ), + ); + } + + StreamMessageTextStyle copyWith({ + StreamMessageStyleProperty? textStyle, + StreamMessageStyleProperty? textColor, + StreamMessageStyleProperty? linkStyle, + StreamMessageStyleProperty? linkColor, + StreamMessageStyleProperty? mentionStyle, + StreamMessageStyleProperty? mentionColor, + StreamMessageStyleProperty? singleEmojiStyle, + StreamMessageStyleProperty? doubleEmojiStyle, + StreamMessageStyleProperty? tripleEmojiStyle, + }) { + final _this = (this as StreamMessageTextStyle); + + return StreamMessageTextStyle( + textStyle: textStyle ?? _this.textStyle, + textColor: textColor ?? _this.textColor, + linkStyle: linkStyle ?? _this.linkStyle, + linkColor: linkColor ?? _this.linkColor, + mentionStyle: mentionStyle ?? _this.mentionStyle, + mentionColor: mentionColor ?? _this.mentionColor, + singleEmojiStyle: singleEmojiStyle ?? _this.singleEmojiStyle, + doubleEmojiStyle: doubleEmojiStyle ?? _this.doubleEmojiStyle, + tripleEmojiStyle: tripleEmojiStyle ?? _this.tripleEmojiStyle, + ); + } + + StreamMessageTextStyle merge(StreamMessageTextStyle? other) { + final _this = (this as StreamMessageTextStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + textStyle: other.textStyle, + textColor: other.textColor, + linkStyle: other.linkStyle, + linkColor: other.linkColor, + mentionStyle: other.mentionStyle, + mentionColor: other.mentionColor, + singleEmojiStyle: other.singleEmojiStyle, + doubleEmojiStyle: other.doubleEmojiStyle, + tripleEmojiStyle: other.tripleEmojiStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageTextStyle); + final _other = (other as StreamMessageTextStyle); + + return _other.textStyle == _this.textStyle && + _other.textColor == _this.textColor && + _other.linkStyle == _this.linkStyle && + _other.linkColor == _this.linkColor && + _other.mentionStyle == _this.mentionStyle && + _other.mentionColor == _this.mentionColor && + _other.singleEmojiStyle == _this.singleEmojiStyle && + _other.doubleEmojiStyle == _this.doubleEmojiStyle && + _other.tripleEmojiStyle == _this.tripleEmojiStyle; + } + + @override + int get hashCode { + final _this = (this as StreamMessageTextStyle); + + return Object.hash( + runtimeType, + _this.textStyle, + _this.textColor, + _this.linkStyle, + _this.linkColor, + _this.mentionStyle, + _this.mentionColor, + _this.singleEmojiStyle, + _this.doubleEmojiStyle, + _this.tripleEmojiStyle, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart index 5446156..e4d85ef 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart @@ -5,15 +5,31 @@ import '../../../stream_core_flutter.dart'; part 'stream_message_theme.g.theme.dart'; +/// Applies a message color theme to descendant widgets. +/// +/// Wrap a subtree with [StreamMessageTheme] to override message colors. +/// Provides separate [StreamMessageStyle] values for incoming and outgoing +/// messages. +/// +/// See also: +/// +/// * [StreamMessageThemeData], which describes the theme data. +/// * [StreamMessageStyle], the color palette for a single message direction. class StreamMessageTheme extends InheritedTheme { + /// Creates a message theme that controls descendant message widget colors. const StreamMessageTheme({ super.key, required this.data, required super.child, }); + /// The message theme data for descendant widgets. final StreamMessageThemeData data; + /// Returns the [StreamMessageThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. static StreamMessageThemeData of(BuildContext context) { final localTheme = context.dependOnInheritedWidgetOfExactType(); return StreamTheme.of(context).messageTheme.merge(localTheme?.data); @@ -28,15 +44,27 @@ class StreamMessageTheme extends InheritedTheme { bool updateShouldNotify(StreamMessageTheme oldWidget) => data != oldWidget.data; } +/// Theme data for customizing message colors. +/// +/// Holds separate color palettes for incoming and outgoing messages. +/// +/// See also: +/// +/// * [StreamMessageTheme], for overriding theme in a widget subtree. +/// * [StreamMessageStyle], the color palette for a single message direction. @themeGen @immutable class StreamMessageThemeData with _$StreamMessageThemeData { + /// Creates message theme data with optional incoming/outgoing overrides. const StreamMessageThemeData({ this.incoming, this.outgoing, }); + /// Color palette for incoming (received) messages. final StreamMessageStyle? incoming; + + /// Color palette for outgoing (sent) messages. final StreamMessageStyle? outgoing; StreamMessageThemeData mergeWithDefaults(BuildContext context) { @@ -45,9 +73,18 @@ class StreamMessageThemeData with _$StreamMessageThemeData { } } +/// Color palette for a single message direction (incoming or outgoing). +/// +/// Groups all color tokens used by message-related widgets: backgrounds, text, +/// borders, progress indicators, and waveform bars. +/// +/// See also: +/// +/// * [StreamMessageThemeData], which wraps this style for theming. @themeGen @immutable class StreamMessageStyle with _$StreamMessageStyle { + /// Creates a message style with optional color overrides. const StreamMessageStyle({ this.backgroundColor, this.backgroundAttachmentColor, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart index f8bcddd..383b139 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart @@ -50,7 +50,6 @@ enum StreamReactionsAlignment { /// spacing: 4, /// gap: 6, /// overlapExtent: 8, -/// indent: 4, /// ), /// child: StreamReactions.segmented( /// items: [ @@ -111,7 +110,6 @@ class StreamReactionsTheme extends InheritedTheme { /// spacing: 4, /// gap: 6, /// overlapExtent: 8, -/// indent: 4, /// ), /// ) /// ``` @@ -130,7 +128,6 @@ class StreamReactionsThemeData with _$StreamReactionsThemeData { this.spacing, this.gap, this.overlapExtent, - this.indent, }); /// The gap between adjacent reaction chips. @@ -146,12 +143,6 @@ class StreamReactionsThemeData with _$StreamReactionsThemeData { /// Higher values move the reactions further into the child. final double? overlapExtent; - /// The horizontal offset applied to the reaction strip. - /// - /// Positive values move reactions toward the trailing side, while negative - /// values move them toward the leading side. - final double? indent; - /// Linearly interpolate between two [StreamReactionsThemeData] objects. static StreamReactionsThemeData? lerp( StreamReactionsThemeData? a, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart index 7b3d87c..30f7195 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart @@ -33,7 +33,6 @@ mixin _$StreamReactionsThemeData { spacing: lerpDouble$(a.spacing, b.spacing, t), gap: lerpDouble$(a.gap, b.gap, t), overlapExtent: lerpDouble$(a.overlapExtent, b.overlapExtent, t), - indent: lerpDouble$(a.indent, b.indent, t), ); } @@ -41,7 +40,6 @@ mixin _$StreamReactionsThemeData { double? spacing, double? gap, double? overlapExtent, - double? indent, }) { final _this = (this as StreamReactionsThemeData); @@ -49,7 +47,6 @@ mixin _$StreamReactionsThemeData { spacing: spacing ?? _this.spacing, gap: gap ?? _this.gap, overlapExtent: overlapExtent ?? _this.overlapExtent, - indent: indent ?? _this.indent, ); } @@ -68,7 +65,6 @@ mixin _$StreamReactionsThemeData { spacing: other.spacing, gap: other.gap, overlapExtent: other.overlapExtent, - indent: other.indent, ); } @@ -87,8 +83,7 @@ mixin _$StreamReactionsThemeData { return _other.spacing == _this.spacing && _other.gap == _this.gap && - _other.overlapExtent == _this.overlapExtent && - _other.indent == _this.indent; + _other.overlapExtent == _this.overlapExtent; } @override @@ -100,7 +95,6 @@ mixin _$StreamReactionsThemeData { _this.spacing, _this.gap, _this.overlapExtent, - _this.indent, ); } } diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 1cbcbbe..754b02b 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -72,6 +72,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundOverlayDark, Color? backgroundDisabled, Color? backgroundInverse, + Color? backgroundHighlight, // Background - Elevation Color? backgroundElevation0, @@ -139,6 +140,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark ??= light_tokens.StreamTokens.backgroundCoreOverlayDark; backgroundDisabled ??= light_tokens.StreamTokens.backgroundCoreDisabled; backgroundInverse ??= light_tokens.StreamTokens.backgroundCoreInverse; + backgroundHighlight ??= StreamColors.yellow.shade50; backgroundElevation0 ??= light_tokens.StreamTokens.backgroundElevationElevation0; backgroundElevation1 ??= light_tokens.StreamTokens.backgroundElevationElevation1; @@ -224,6 +226,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark: backgroundOverlayDark, backgroundDisabled: backgroundDisabled, backgroundInverse: backgroundInverse, + backgroundHighlight: backgroundHighlight, backgroundElevation0: backgroundElevation0, backgroundElevation1: backgroundElevation1, backgroundElevation2: backgroundElevation2, @@ -284,6 +287,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundOverlayDark, Color? backgroundDisabled, Color? backgroundInverse, + Color? backgroundHighlight, // Background - Elevation Color? backgroundElevation0, Color? backgroundElevation1, @@ -349,6 +353,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark ??= dark_tokens.StreamTokens.backgroundCoreOverlayDark; backgroundDisabled ??= dark_tokens.StreamTokens.backgroundCoreDisabled; backgroundInverse ??= dark_tokens.StreamTokens.backgroundCoreInverse; + backgroundHighlight ??= StreamColors.yellow.shade800; backgroundElevation0 ??= dark_tokens.StreamTokens.backgroundElevationElevation0; backgroundElevation1 ??= dark_tokens.StreamTokens.backgroundElevationElevation1; @@ -434,6 +439,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark: backgroundOverlayDark, backgroundDisabled: backgroundDisabled, backgroundInverse: backgroundInverse, + backgroundHighlight: backgroundHighlight, backgroundElevation0: backgroundElevation0, backgroundElevation1: backgroundElevation1, backgroundElevation2: backgroundElevation2, @@ -492,6 +498,7 @@ class StreamColorScheme with _$StreamColorScheme { required this.backgroundOverlayDark, required this.backgroundDisabled, required this.backgroundInverse, + required this.backgroundHighlight, // Background - Elevation required this.backgroundElevation0, required this.backgroundElevation1, @@ -607,6 +614,9 @@ class StreamColorScheme with _$StreamColorScheme { /// The inverse background color. final Color backgroundInverse; + /// The highlight background color for pinned or accented items. + final Color backgroundHighlight; + // ---- Background - Elevation ---- /// The elevation 0 background color. diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 262e994..c0b8a1f 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -86,6 +86,11 @@ mixin _$StreamColorScheme { b.backgroundInverse, t, )!, + backgroundHighlight: Color.lerp( + a.backgroundHighlight, + b.backgroundHighlight, + t, + )!, backgroundElevation0: Color.lerp( a.backgroundElevation0, b.backgroundElevation0, @@ -161,6 +166,7 @@ mixin _$StreamColorScheme { Color? backgroundOverlayDark, Color? backgroundDisabled, Color? backgroundInverse, + Color? backgroundHighlight, Color? backgroundElevation0, Color? backgroundElevation1, Color? backgroundElevation2, @@ -220,6 +226,7 @@ mixin _$StreamColorScheme { backgroundOverlayDark ?? _this.backgroundOverlayDark, backgroundDisabled: backgroundDisabled ?? _this.backgroundDisabled, backgroundInverse: backgroundInverse ?? _this.backgroundInverse, + backgroundHighlight: backgroundHighlight ?? _this.backgroundHighlight, backgroundElevation0: backgroundElevation0 ?? _this.backgroundElevation0, backgroundElevation1: backgroundElevation1 ?? _this.backgroundElevation1, backgroundElevation2: backgroundElevation2 ?? _this.backgroundElevation2, @@ -286,6 +293,7 @@ mixin _$StreamColorScheme { backgroundOverlayDark: other.backgroundOverlayDark, backgroundDisabled: other.backgroundDisabled, backgroundInverse: other.backgroundInverse, + backgroundHighlight: other.backgroundHighlight, backgroundElevation0: other.backgroundElevation0, backgroundElevation1: other.backgroundElevation1, backgroundElevation2: other.backgroundElevation2, @@ -353,6 +361,7 @@ mixin _$StreamColorScheme { _other.backgroundOverlayDark == _this.backgroundOverlayDark && _other.backgroundDisabled == _this.backgroundDisabled && _other.backgroundInverse == _this.backgroundInverse && + _other.backgroundHighlight == _this.backgroundHighlight && _other.backgroundElevation0 == _this.backgroundElevation0 && _other.backgroundElevation1 == _this.backgroundElevation1 && _other.backgroundElevation2 == _this.backgroundElevation2 && @@ -412,6 +421,7 @@ mixin _$StreamColorScheme { _this.backgroundOverlayDark, _this.backgroundDisabled, _this.backgroundInverse, + _this.backgroundHighlight, _this.backgroundElevation0, _this.backgroundElevation1, _this.backgroundElevation2, 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 f0d35e2..2aa5591 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -16,6 +16,7 @@ import 'components/stream_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_item_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -105,6 +106,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, + StreamMessageItemThemeData? messageItemTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -137,6 +139,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme ??= const StreamEmojiButtonThemeData(); emojiChipTheme ??= const StreamEmojiChipThemeData(); listTileTheme ??= const StreamListTileThemeData(); + messageItemTheme ??= const StreamMessageItemThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -163,6 +166,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, + messageItemTheme: messageItemTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, @@ -203,6 +207,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiButtonTheme, required this.emojiChipTheme, required this.listTileTheme, + required this.messageItemTheme, required this.messageTheme, required this.inputTheme, required this.onlineIndicatorTheme, @@ -301,6 +306,11 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; + /// The message item theme for this theme. + /// + /// Provides resolver-based styling for message sub-components. + final StreamMessageItemThemeData messageItemTheme; + /// The message theme for this theme. final StreamMessageThemeData messageTheme; @@ -356,6 +366,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, + messageItemTheme: messageItemTheme, 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 46d74ea..246ec8b 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 @@ -31,6 +31,7 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, + StreamMessageItemThemeData? messageItemTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -61,6 +62,7 @@ mixin _$StreamTheme on ThemeExtension { emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, emojiChipTheme: emojiChipTheme ?? _this.emojiChipTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, + messageItemTheme: messageItemTheme ?? _this.messageItemTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, @@ -145,6 +147,11 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, + messageItemTheme: StreamMessageItemThemeData.lerp( + _this.messageItemTheme, + other.messageItemTheme, + t, + )!, messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, inputTheme: t < 0.5 ? _this.inputTheme : other.inputTheme, onlineIndicatorTheme: StreamOnlineIndicatorThemeData.lerp( @@ -197,6 +204,7 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiButtonTheme == _this.emojiButtonTheme && _other.emojiChipTheme == _this.emojiChipTheme && _other.listTileTheme == _this.listTileTheme && + _other.messageItemTheme == _this.messageItemTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && @@ -229,6 +237,7 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiButtonTheme, _this.emojiChipTheme, _this.listTileTheme, + _this.messageItemTheme, _this.messageTheme, _this.inputTheme, _this.onlineIndicatorTheme, 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 3321b72..72c08cb 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 @@ -12,6 +12,7 @@ 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_item_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -103,6 +104,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMessageItemThemeData] from the nearest ancestor. + StreamMessageItemThemeData get streamMessageItemTheme => StreamMessageItemTheme.of(this); + /// Returns the [StreamMessageThemeData] from the nearest ancestor. StreamMessageThemeData get streamMessageTheme => StreamMessageTheme.of(this); diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index e5eb18c..9f1f817 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -12,7 +12,9 @@ dependencies: collection: ^1.19.0 flutter: sdk: flutter + flutter_markdown_plus: ^1.0.7 flutter_svg: ^2.2.3 + markdown: ^7.3.0 stream_core: ^0.4.0 theme_extensions_builder_annotation: ^7.1.0