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 a9a5b671..e6c4db4d 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 @@ -98,12 +98,16 @@ import 'package:design_system_gallery/components/message/stream_message_text.dar as _design_system_gallery_components_message_stream_message_text; 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_edit_message.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_edit_message; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' as _design_system_gallery_components_message_composer_message_composer_attachment_link_preview; -import 'package:design_system_gallery/components/message_composer/message_composer_attachment_media_file.dart' - as _design_system_gallery_components_message_composer_message_composer_attachment_media_file; +import 'package:design_system_gallery/components/message_composer/message_composer_attachment_media.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_media; 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/message_composer/message_composer_attachment_unsupported.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_unsupported; import 'package:design_system_gallery/components/message_composer/message_composer_file_attachment.dart' as _design_system_gallery_components_message_composer_message_composer_file_attachment; import 'package:design_system_gallery/components/reaction/stream_reaction_picker.dart' @@ -996,24 +1000,58 @@ final directories = <_widgetbook.WidgetbookNode>[ name: 'Message Composer', children: [ _widgetbook.WidgetbookComponent( - name: 'MessageComposerFileAttachment', + name: 'StreamCoreMessageComposer', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerExample, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageComposerEditMessageAttachment', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_composer_message_composer_attachment_edit_message + .buildMessageComposerAttachmentEditMessagePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_composer_message_composer_attachment_edit_message + .buildMessageComposerAttachmentEditMessageShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageComposerFileAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: _design_system_gallery_components_message_composer_message_composer_file_attachment - .buildMessageComposerFileAttachmentPlayground, + .buildStreamMessageComposerFileAttachmentPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: _design_system_gallery_components_message_composer_message_composer_file_attachment - .buildMessageComposerFileAttachmentShowcase, + .buildStreamMessageComposerFileAttachmentShowcase, ), ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerLinkPreviewAttachment', + name: 'StreamMessageComposerLinkPreviewAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -1030,24 +1068,24 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerMediaFileAttachment', + name: 'StreamMessageComposerMediaAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_message_composer_message_composer_attachment_media_file - .buildMessageComposerAttachmentMediaFilePlayground, + _design_system_gallery_components_message_composer_message_composer_attachment_media + .buildMessageComposerAttachmentMediaPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_message_composer_message_composer_attachment_media_file - .buildMessageComposerAttachmentMediaFileShowcase, + _design_system_gallery_components_message_composer_message_composer_attachment_media + .buildMessageComposerAttachmentMediaShowcase, ), ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerReplyAttachment', + name: 'StreamMessageComposerReplyAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -1064,19 +1102,19 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'StreamCoreMessageComposer', + name: 'StreamMessageComposerUnsupportedAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_message_composer_message_composer - .buildStreamMessageComposerPlayground, + _design_system_gallery_components_message_composer_message_composer_attachment_unsupported + .buildMessageComposerAttachmentUnsupportedPlayground, ), _widgetbook.WidgetbookUseCase( - name: 'Real-world Example', + name: 'Showcase', builder: - _design_system_gallery_components_message_composer_message_composer - .buildStreamMessageComposerExample, + _design_system_gallery_components_message_composer_message_composer_attachment_unsupported + .buildMessageComposerAttachmentUnsupportedShowcase, ), ], ), diff --git a/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart b/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart index 9abf82a6..1c07bef9 100644 --- a/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart +++ b/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart @@ -94,13 +94,13 @@ Widget buildStreamIntrinsicFlexPlayground(BuildContext context) { ); // null = hidden - final childConfigs = <({_ChildKind kind, bool candidate, _ChildAlignment alignment})?>[]; + final childConfigs = <({_ChildKind kind, bool candidate, bool bounded, _ChildAlignment alignment})?>[]; const defaults = [ - (_ChildKind.fixedMedium, true, _ChildAlignment.start), - (_ChildKind.expanded, false, _ChildAlignment.start), - (_ChildKind.fixedLarge, false, _ChildAlignment.start), - (_ChildKind.aligned, false, _ChildAlignment.end), - (_ChildKind.fixedSmall, true, _ChildAlignment.start), + (_ChildKind.fixedMedium, true, false, _ChildAlignment.start), + (_ChildKind.expanded, false, false, _ChildAlignment.start), + (_ChildKind.fixedLarge, false, false, _ChildAlignment.start), + (_ChildKind.aligned, false, false, _ChildAlignment.end), + (_ChildKind.fixedSmall, true, false, _ChildAlignment.start), ]; for (var i = 0; i < 5; i++) { @@ -127,20 +127,27 @@ Widget buildStreamIntrinsicFlexPlayground(BuildContext context) { initialValue: defaults[i].$2, description: 'Mark child ${i + 1} as a StreamIntrinsicSizeCandidate.', ); + final bounded = context.knobs.boolean( + label: 'Child ${i + 1} bounded', + initialValue: defaults[i].$3, + description: + 'Wrap child ${i + 1} in StreamIntrinsicBoundedCrossAxis ' + "so it is measured under the parent's cross-axis ceiling.", + ); final isVertical = direction == Axis.vertical; - var alignment = defaults[i].$3; + var alignment = defaults[i].$4; if (kind == _ChildKind.aligned) { alignment = context.knobs.object.dropdown( label: 'Child ${i + 1} alignment', options: _ChildAlignment.values, labelBuilder: (value) => value.label(isVertical: isVertical), - initialOption: defaults[i].$3, + initialOption: defaults[i].$4, description: 'Alignment for child ${i + 1}.', ); } - childConfigs.add((kind: kind, candidate: candidate, alignment: alignment)); + childConfigs.add((kind: kind, candidate: candidate, bounded: bounded, alignment: alignment)); } return _PlaygroundBody( @@ -168,11 +175,11 @@ class _PlaygroundBody extends StatelessWidget { final MainAxisSize mainAxisSize; final double spacing; final CrossAxisAlignment crossAxisAlignment; - final List<({_ChildKind kind, bool candidate, _ChildAlignment alignment})?> childConfigs; + final List<({_ChildKind kind, bool candidate, bool bounded, _ChildAlignment alignment})?> childConfigs; List _buildChildren( BuildContext context, { - bool wrapCandidates = true, + bool wrapMarkers = true, }) { final textTheme = context.streamTextTheme; final radius = context.streamRadius; @@ -235,9 +242,12 @@ class _PlaygroundBody extends StatelessWidget { ), }; - if (wrapCandidates && config.candidate) { + if (wrapMarkers && config.candidate) { child = StreamIntrinsicSizeCandidate(child: child); } + if (wrapMarkers && config.bounded) { + child = StreamIntrinsicBoundedCrossAxis(child: child); + } return child; }(), ]; @@ -301,7 +311,7 @@ class _PlaygroundBody extends StatelessWidget { mainAxisSize: mainAxisSize, crossAxisAlignment: crossAxisAlignment, spacing: spacing, - children: _buildChildren(context, wrapCandidates: false), + children: _buildChildren(context, wrapMarkers: false), ), ), ), @@ -379,6 +389,7 @@ Widget buildStreamIntrinsicFlexShowcase(BuildContext context) { children: const [ _ShrinkWrapSection(), _SizeCandidatesSection(), + _BoundedChildrenSection(), _CrossAxisAlignmentSection(), _BaselineAlignmentSection(), _NegativeSpacingSection(), @@ -824,6 +835,180 @@ Widget _wrapCandidate({required bool isCandidate, required Widget child}) { return child; } +// ============================================================================= +// Bounded Children Section +// ============================================================================= + +class _BoundedChildrenSection extends StatelessWidget { + const _BoundedChildrenSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'BOUNDED CHILDREN'), + _ExampleCard( + title: 'Marker changes how a child is measured', + description: + 'StreamIntrinsicColumn measures children with an unbounded ' + 'cross-axis by default, so Align shrink-wraps. Wrap a child ' + "in StreamIntrinsicBoundedCrossAxis to give it the parent's " + 'cross-axis ceiling — Align then fills, and the column resolves ' + 'to that wider extent.', + child: _BoundedAlignDemo(), + ), + _ExampleCard( + title: 'Hosting a ListView(shrinkWrap: true)', + description: + 'A vertical viewport asserts when given an unbounded width. ' + 'Wrapping the child in StreamIntrinsicBoundedCrossAxis hands ' + 'it a bounded width during pass-1, so a shrink-wrapping ' + 'ListView (or Wrap) inside it can lay out without asserting.', + child: _BoundedListViewDemo(), + ), + ], + ); + } +} + +class _BoundedAlignDemo extends StatelessWidget { + const _BoundedAlignDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + Widget alignedChild() => Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.16), + borderRadius: BorderRadius.all(radius.sm), + ), + child: Text( + 'aligned right', + style: textTheme.metadataEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ), + ); + + Widget card({ + required String label, + required Widget alignChild, + required bool accent, + }) { + final accentColor = accent ? colorScheme.accentPrimary : colorScheme.textTertiary; + return _VariantDemo( + label: label, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: DecoratedBox( + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: accentColor.withValues(alpha: 0.3)), + ), + child: StreamIntrinsicColumn( + spacing: spacing.xs, + children: [ + _ColoredBar(width: 80, label: 'Fixed 80', palette: _childPalette[0]), + alignChild, + ], + ), + ), + ), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.lg, + children: [ + Expanded( + child: card( + label: 'unmarked', + alignChild: alignedChild(), + accent: false, + ), + ), + Expanded( + child: card( + label: 'StreamIntrinsicBoundedCrossAxis', + alignChild: StreamIntrinsicBoundedCrossAxis(child: alignedChild()), + accent: true, + ), + ), + ], + ); + } +} + +class _BoundedListViewDemo extends StatelessWidget { + const _BoundedListViewDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final list = ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 4, + separatorBuilder: (_, _) => Divider(height: 1, color: colorScheme.borderSubtle), + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Text( + 'Item ${i + 1}', + style: textTheme.metadataEmphasis.copyWith(color: colorScheme.textPrimary), + ), + ), + ); + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.accentPrimary.withValues(alpha: 0.3)), + ), + child: StreamIntrinsicColumn( + spacing: spacing.xs, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Text( + 'Header', + style: textTheme.captionEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ), + StreamIntrinsicBoundedCrossAxis(child: list), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Text( + 'Footer', + style: textTheme.captionEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ), + ], + ), + ), + ); + } +} + // ============================================================================= // Cross-Axis Alignment Section // ============================================================================= diff --git a/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart b/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart index d53a8061..0d9131fc 100644 --- a/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart +++ b/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart @@ -96,18 +96,21 @@ class _InteractiveDemoState extends State<_InteractiveDemo> { style: widget.showHitRegion ? BorderStyle.solid : BorderStyle.none, ), ), - child: StreamTapTargetPadding( - minSize: widget.minSize, - alignment: widget.alignment, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _taps++), - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: colorScheme.accentPrimary, - shape: BoxShape.circle, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: StreamTapTargetPadding( + minSize: widget.minSize, + alignment: widget.alignment, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _taps++), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + shape: BoxShape.circle, + ), ), ), ), diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart new file mode 100644 index 00000000..fe5d9e31 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart @@ -0,0 +1,291 @@ +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: StreamMessageComposerEditMessageAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentEditMessagePlayground(BuildContext context) { + final title = context.knobs.string( + label: 'Title', + initialValue: 'Edit message', + description: 'The title line, typically a localized "Edit message" label.', + ); + + final subtitle = context.knobs.string( + label: 'Subtitle', + initialValue: 'See you at 9!', + description: 'The subtitle line, typically the body of the message being edited.', + ); + + final showThumbnail = context.knobs.boolean( + label: 'Show Thumbnail', + description: 'Toggle a trailing image thumbnail.', + ); + + final showRemoveButton = context.knobs.boolean( + label: 'Show Remove Button', + initialValue: true, + description: 'Toggle the remove attachment control.', + ); + + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 360, + min: 200, + max: 600, + description: 'Bounds the parent width. The preview fills this width.', + ); + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerEditMessageAttachment( + title: Text(title), + subtitle: Text(subtitle), + thumbnail: showThumbnail ? _Thumbnail() : null, + onRemovePressed: showRemoveButton ? () {} : null, + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageComposerEditMessageAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentEditMessageShowcase(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _BasicSection(), + _ThumbnailSection(), + _FileThumbnailSection(), + _ConstrainedSection(), + ], + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _BasicSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'BASIC', + description: 'Edit-message preview with brand-tinted background and indicator.', + children: [ + _ExampleCard( + label: 'Short message', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('See you at 9!'), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'Long message', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('We had a great time during our holiday and the views were stunning all week.'), + onRemovePressed: () {}, + ), + ), + ], + ); + } +} + +class _ThumbnailSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'WITH THUMBNAIL', + description: 'A trailing thumbnail for edits to messages with attached media.', + children: [ + _ExampleCard( + label: 'With image thumbnail', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('Here is the document you requested.'), + thumbnail: _Thumbnail(), + onRemovePressed: () {}, + ), + ), + ], + ); + } +} + +class _FileThumbnailSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'WITH FILE ICON', + description: 'A file-type icon as the thumbnail for editing non-media attachments.', + children: [ + _ExampleCard( + label: 'With PDF', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('annual_report.pdf'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.pdf), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'With spreadsheet', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('project_budget.xlsx'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.spreadsheet), + onRemovePressed: () {}, + ), + ), + ], + ); + } +} + +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'When a parent bounds the width, the message body ellipsizes on a single ' + 'line rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 280', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text( + 'A long message body that exceeds the available width and ellipsizes.', + ), + onRemovePressed: () {}, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Thumbnail extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.md), + image: const DecorationImage( + image: AssetImage('assets/attachment_image.png'), + fit: BoxFit.cover, + ), + ), + ); + } +} + +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: [ + Text( + label, + style: context.streamTextTheme.metadataEmphasis.copyWith( + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ), + if (description case final desc?) + Text(desc, style: context.streamTextTheme.metadataDefault.copyWith(color: colorScheme.textTertiary)), + ], + ), + ...children, + ], + ); + } +} + +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: context.streamTextTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary), + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart index 67e83d09..e9221fb6 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart @@ -9,7 +9,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerLinkPreviewAttachment, + type: StreamMessageComposerLinkPreviewAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) { @@ -26,12 +26,12 @@ Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) final url = context.knobs.string( label: 'URL', initialValue: 'https://getstream.io/chat/docs/', - description: 'The link URL displayed in the preview.', + description: 'The link URL shown in the caption row.', ); - final showImage = context.knobs.boolean( - label: 'Show Image', + final showMedia = context.knobs.boolean( + label: 'Show Media', initialValue: true, - description: 'Toggle the link preview thumbnail image.', + description: 'Toggle the link preview thumbnail media.', ); final showRemoveButton = context.knobs.boolean( label: 'Show Remove Button', @@ -39,14 +39,24 @@ Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) description: 'Toggle the remove attachment control.', ); + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 360, + min: 200, + max: 600, + description: + 'Bounds the parent width. Values below 290 force the preview to shrink ' + 'below its natural minimum.', + ); + return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: MessageComposerLinkPreviewAttachment( - title: title.isEmpty ? null : title, - subtitle: subtitle.isEmpty ? null : subtitle, - url: url.isEmpty ? null : url, - image: showImage ? const AssetImage('assets/attachment_image.png') : null, + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerLinkPreviewAttachment( + title: title.isEmpty ? null : Text(title), + subtitle: subtitle.isEmpty ? null : Text(subtitle), + caption: url.isEmpty ? null : _UrlCaption(url: url), + thumbnail: showMedia ? const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover) : null, onRemovePressed: showRemoveButton ? () {} : null, ), ), @@ -59,7 +69,7 @@ Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerLinkPreviewAttachment, + type: StreamMessageComposerLinkPreviewAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentLinkPreviewShowcase(BuildContext context) { @@ -71,6 +81,8 @@ Widget buildMessageComposerAttachmentLinkPreviewShowcase(BuildContext context) { children: [ _FullPreviewSection(), _PartialPreviewSection(), + _MediaCustomizationSection(), + _ConstrainedSection(), ], ), ); @@ -83,17 +95,17 @@ Widget buildMessageComposerAttachmentLinkPreviewShowcase(BuildContext context) { class _FullPreviewSection extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Section( + return _Section( label: 'FULL PREVIEW', description: 'All fields populated: image, title, subtitle, and URL.', children: [ _ExampleCard( label: 'Complete link preview', - child: MessageComposerLinkPreviewAttachment( - title: 'Stream Chat Flutter SDK', - subtitle: 'Build real-time chat with our powerful Flutter SDK.', - url: 'https://getstream.io/chat/sdk/flutter/', - image: AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Stream Chat Flutter SDK'), + subtitle: const Text('Build real-time chat with our powerful Flutter SDK.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/sdk/flutter/'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), ], @@ -104,29 +116,103 @@ class _FullPreviewSection extends StatelessWidget { class _PartialPreviewSection extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Section( + return _Section( label: 'PARTIAL PREVIEWS', - description: 'Each field is optional. The layout adapts to available content.', + description: 'Each field is optional. Null fields are simply omitted from the layout.', + children: [ + _ExampleCard( + label: 'Title + caption only', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Flutter Documentation'), + caption: const _UrlCaption(url: 'https://docs.flutter.dev'), + ), + ), + _ExampleCard( + label: 'Caption only', + child: StreamMessageComposerLinkPreviewAttachment( + caption: const _UrlCaption(url: 'https://getstream.io'), + ), + ), + _ExampleCard( + label: 'Media + title + subtitle (no caption)', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Beautiful Landscapes'), + subtitle: const Text('A collection of stunning nature photography.'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + ), + ], + ); + } +} + +class _MediaCustomizationSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return _Section( + label: 'MEDIA CUSTOMIZATION', + description: + 'The thumbnail size, shape, and border side are themable. Defaults to a 40×40 ' + 'rounded-superellipse with no border.', children: [ _ExampleCard( - label: 'Title + URL only', - child: MessageComposerLinkPreviewAttachment( - title: 'Flutter Documentation', - url: 'https://docs.flutter.dev', + label: 'Larger 56×56 thumbnail', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Stream Chat Flutter SDK'), + subtitle: const Text('Build real-time chat with our powerful Flutter SDK.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/sdk/flutter/'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + style: const StreamMessageComposerLinkPreviewAttachmentThemeData(thumbnailSize: Size.square(56)), ), ), _ExampleCard( - label: 'URL only', - child: MessageComposerLinkPreviewAttachment( - url: 'https://getstream.io', + label: 'Circular thumbnail', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Avatar-style favicon'), + subtitle: const Text('Custom shape via thumbnailShape.'), + caption: const _UrlCaption(url: 'https://getstream.io'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + style: const StreamMessageComposerLinkPreviewAttachmentThemeData(thumbnailShape: CircleBorder()), ), ), _ExampleCard( - label: 'Image + title + subtitle (no URL)', - child: MessageComposerLinkPreviewAttachment( - title: 'Beautiful Landscapes', - subtitle: 'A collection of stunning nature photography.', - image: AssetImage('assets/attachment_image.png'), + label: 'With media border', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Bordered thumbnail'), + subtitle: const Text('Side composed onto the shape via thumbnailSide.'), + caption: const _UrlCaption(url: 'https://getstream.io'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + style: StreamMessageComposerLinkPreviewAttachmentThemeData( + thumbnailSide: BorderSide(color: colorScheme.borderDefault), + ), + ), + ), + ], + ); + } +} + +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'The preview targets a minimum width of 290 so content has room to breathe. When a ' + 'parent bounds the width, the preview shrinks to fit and long text ellipsizes on a ' + 'single line rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 280 (ellipsizes)', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('A very long page title that will not fit on a single line at this width'), + subtitle: const Text('And an even longer description that also exceeds the available space.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/sdk/flutter/long-path/another-segment'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), ), ), ], @@ -138,6 +224,24 @@ class _PartialPreviewSection extends StatelessWidget { // Helper Widgets // ============================================================================= +class _UrlCaption extends StatelessWidget { + const _UrlCaption({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(context.streamIcons.link, size: 12), + Flexible(child: Text(url)), + ], + ); + } +} + class _Section extends StatelessWidget { const _Section({ required this.label, diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media.dart similarity index 64% rename from apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart rename to apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media.dart index d88b64f6..1c1cdf26 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media.dart @@ -9,10 +9,18 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerMediaFileAttachment, + type: StreamMessageComposerMediaAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { +Widget buildMessageComposerAttachmentMediaPlayground(BuildContext context) { + final size = context.knobs.double.slider( + label: 'Thumbnail Size', + initialValue: 72, + min: 48, + max: 160, + description: 'The square thumbnail edge length, in logical pixels.', + ); + final showBadge = context.knobs.boolean( label: 'Show Media Badge', description: 'Toggle a source badge overlay (e.g. Giphy).', @@ -34,12 +42,13 @@ Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { ); return Center( - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: showRemoveButton ? () {} : null, mediaBadge: badgeType != null ? StreamMediaBadge(type: badgeType, duration: const Duration(minutes: 2, seconds: 34)) : null, + style: StreamMessageComposerMediaAttachmentThemeData(size: Size.square(size)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ); } @@ -50,10 +59,10 @@ Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerMediaFileAttachment, + type: StreamMessageComposerMediaAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerAttachmentMediaFileShowcase(BuildContext context) { +Widget buildMessageComposerAttachmentMediaShowcase(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( @@ -61,6 +70,7 @@ Widget buildMessageComposerAttachmentMediaFileShowcase(BuildContext context) { spacing: 32, children: [ _BasicSection(), + _SizeSection(), _BadgeSection(), _CompositionSection(), ], @@ -81,16 +91,58 @@ class _BasicSection extends StatelessWidget { children: [ _ExampleCard( label: 'Image attachment', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), _ExampleCard( label: 'Without remove button', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), - onRemovePressed: null, + child: StreamMessageComposerMediaAttachment( + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + ), + ], + ); + } +} + +class _SizeSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return _Section( + label: 'SIZE', + description: 'Override the thumbnail size via the per-instance style.', + children: [ + _ExampleCard( + label: 'Small (56), Default (72), Medium (96), Large (128)', + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + spacing: spacing.sm, + runSpacing: spacing.sm, + children: [ + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + style: const StreamMessageComposerMediaAttachmentThemeData(size: Size.square(56)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + style: const StreamMessageComposerMediaAttachmentThemeData(size: Size.square(96)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + style: const StreamMessageComposerMediaAttachmentThemeData(size: Size.square(128)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + ], ), ), ], @@ -107,24 +159,24 @@ class _BadgeSection extends StatelessWidget { children: [ _ExampleCard( label: 'Video badge', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: () {}, mediaBadge: const StreamMediaBadge( type: MediaBadgeType.video, duration: Duration(minutes: 1, seconds: 42), ), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), _ExampleCard( label: 'Audio badge', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: () {}, mediaBadge: const StreamMediaBadge( type: MediaBadgeType.audio, duration: Duration(seconds: 30), ), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), ], @@ -148,23 +200,23 @@ class _CompositionSection extends StatelessWidget { child: ListView( scrollDirection: Axis.horizontal, children: [ - MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + StreamMessageComposerMediaAttachment( onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), SizedBox(width: spacing.xxs), - MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + StreamMessageComposerMediaAttachment( onRemovePressed: () {}, mediaBadge: const StreamMediaBadge( type: MediaBadgeType.video, duration: Duration(minutes: 3, seconds: 15), ), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), SizedBox(width: spacing.xxs), - MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + StreamMessageComposerMediaAttachment( onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ], ), diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart index 08449bd3..b5f86d9d 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart @@ -9,7 +9,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerReplyAttachment, + type: StreamMessageComposerReplyAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { @@ -25,10 +25,10 @@ Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { description: 'The subtitle line, typically the message preview.', ); - final style = context.knobs.object.dropdown( + final style = context.knobs.object.dropdown( label: 'Style', - options: ReplyStyle.values, - initialOption: ReplyStyle.incoming, + options: StreamReplyDirection.values, + initialOption: StreamReplyDirection.incoming, labelBuilder: (option) => option.name, description: 'Incoming uses left-hand bar and incoming colors; outgoing uses right-hand bar and outgoing colors.', ); @@ -44,15 +44,23 @@ Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { description: 'Toggle the remove attachment control.', ); + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 360, + min: 200, + max: 600, + description: 'Bounds the parent width. The preview fills this width.', + ); + return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: MessageComposerReplyAttachment( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerReplyAttachment( title: Text(title), subtitle: Text(subtitle), - trailing: showThumbnail ? _Thumbnail() : null, + thumbnail: showThumbnail ? _Thumbnail() : null, onRemovePressed: showRemoveButton ? () {} : null, - style: style, + direction: style, ), ), ); @@ -64,7 +72,7 @@ Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerReplyAttachment, + type: StreamMessageComposerReplyAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentReplyShowcase(BuildContext context) { @@ -76,6 +84,8 @@ Widget buildMessageComposerAttachmentReplyShowcase(BuildContext context) { children: [ _ReplyStyleSection(), _ThumbnailSection(), + _FileThumbnailSection(), + _ConstrainedSection(), ], ), ); @@ -94,7 +104,7 @@ class _ReplyStyleSection extends StatelessWidget { children: [ _ExampleCard( label: 'Incoming', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to Alice'), subtitle: const Text('Did you see the sunset yesterday?'), onRemovePressed: () {}, @@ -102,11 +112,11 @@ class _ReplyStyleSection extends StatelessWidget { ), _ExampleCard( label: 'Outgoing', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to You'), subtitle: const Text('Sure, I can help with that!'), onRemovePressed: () {}, - style: ReplyStyle.outgoing, + direction: StreamReplyDirection.outgoing, ), ), ], @@ -123,21 +133,79 @@ class _ThumbnailSection extends StatelessWidget { children: [ _ExampleCard( label: 'Incoming with image', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to Bob'), subtitle: const Text('Check out this photo from our trip!'), - trailing: _Thumbnail(), + thumbnail: _Thumbnail(), onRemovePressed: () {}, ), ), _ExampleCard( label: 'Outgoing with image', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to You'), subtitle: const Text('Here is the document you requested.'), - trailing: _Thumbnail(), + thumbnail: _Thumbnail(), + onRemovePressed: () {}, + direction: StreamReplyDirection.outgoing, + ), + ), + ], + ); + } +} + +class _FileThumbnailSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'WITH FILE ICON', + description: 'A file-type icon as the thumbnail for replies to non-media attachments.', + children: [ + _ExampleCard( + label: 'Incoming with PDF', + child: StreamMessageComposerReplyAttachment( + title: const Text('Reply to Alice'), + subtitle: const Text('annual_report.pdf'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.pdf), onRemovePressed: () {}, - style: ReplyStyle.outgoing, + ), + ), + _ExampleCard( + label: 'Outgoing with spreadsheet', + child: StreamMessageComposerReplyAttachment( + title: const Text('Reply to You'), + subtitle: const Text('project_budget.xlsx'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.spreadsheet), + onRemovePressed: () {}, + direction: StreamReplyDirection.outgoing, + ), + ), + ], + ); + } +} + +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'When a parent bounds the width, long titles and subtitles ellipsize on a ' + 'single line rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 280', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: StreamMessageComposerReplyAttachment( + title: const Text('Reply to Charlotte Bennett-Williams'), + subtitle: const Text( + 'Here is a very long message that will not fit on a single line at this width.', + ), + onRemovePressed: () {}, + ), ), ), ], diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart new file mode 100644 index 00000000..654a5ccb --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart @@ -0,0 +1,196 @@ +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: StreamMessageComposerUnsupportedAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentUnsupportedPlayground(BuildContext context) { + final label = context.knobs.string( + label: 'Label', + initialValue: 'Unsupported attachment', + description: 'The placeholder label, typically a localized "Unsupported attachment" string.', + ); + + final showRemoveButton = context.knobs.boolean( + label: 'Show Remove Button', + initialValue: true, + description: 'Toggle the remove attachment control.', + ); + + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 320, + min: 150, + max: 500, + description: 'Bounds the parent width. Values below 260 force the row to shrink below its natural maximum.', + ); + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerUnsupportedAttachment( + label: Text(label), + onRemovePressed: showRemoveButton ? () {} : null, + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageComposerUnsupportedAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentUnsupportedShowcase(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _BasicSection(), + ], + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _BasicSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'BASIC', + description: + 'A placeholder for attachments the client cannot render. Fixed at 260 wide; ' + 'height adapts to the content.', + children: [ + _ExampleCard( + label: 'With remove button', + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('Unsupported attachment'), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'Without remove button', + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('Unsupported attachment'), + ), + ), + _ExampleCard( + label: 'Long label (ellipsizes)', + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('This particular attachment type is not yet supported on this client'), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'Constrained max width (220)', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('A parent below 260 forces the row to shrink and the label ellipsizes.'), + onRemovePressed: () {}, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// 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: [ + Text( + label, + style: context.streamTextTheme.metadataEmphasis.copyWith( + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ), + if (description case final desc?) + Text(desc, style: context.streamTextTheme.metadataDefault.copyWith(color: colorScheme.textTertiary)), + ], + ), + ...children, + ], + ); + } +} + +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: context.streamTextTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary), + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart index 2db719e5..3bee69a1 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart @@ -9,10 +9,10 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerFileAttachment, + type: StreamMessageComposerFileAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerFileAttachmentPlayground(BuildContext context) { +Widget buildStreamMessageComposerFileAttachmentPlayground(BuildContext context) { final title = context.knobs.string( label: 'Title', initialValue: 'quarterly_report.pdf', @@ -39,10 +39,18 @@ Widget buildMessageComposerFileAttachmentPlayground(BuildContext context) { description: 'Toggle the remove attachment control.', ); + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 320, + min: 150, + max: 500, + description: 'Bounds the parent width. Values below 260 force the row to shrink below its natural maximum.', + ); + return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: MessageComposerFileAttachment( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: fileType), title: Text(title), subtitle: showSubtitle @@ -63,10 +71,10 @@ Widget buildMessageComposerFileAttachmentPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerFileAttachment, + type: StreamMessageComposerFileAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerFileAttachmentShowcase(BuildContext context) { +Widget buildStreamMessageComposerFileAttachmentShowcase(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( @@ -75,6 +83,7 @@ Widget buildMessageComposerFileAttachmentShowcase(BuildContext context) { children: [ _FileTypesSection(), _WithSubtitleSection(), + _ConstrainedSection(), ], ), ); @@ -93,7 +102,7 @@ class _FileTypesSection extends StatelessWidget { children: [ _ExampleCard( label: 'PDF', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.pdf), title: const Text('annual_report.pdf'), onRemovePressed: () {}, @@ -101,7 +110,7 @@ class _FileTypesSection extends StatelessWidget { ), _ExampleCard( label: 'Document', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.text), title: const Text('meeting_notes.docx'), onRemovePressed: () {}, @@ -109,7 +118,7 @@ class _FileTypesSection extends StatelessWidget { ), _ExampleCard( label: 'Spreadsheet', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.spreadsheet), title: const Text('project_budget.xlsx'), onRemovePressed: () {}, @@ -117,7 +126,7 @@ class _FileTypesSection extends StatelessWidget { ), _ExampleCard( label: 'Audio', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.audio), title: const Text('podcast_episode.mp3'), onRemovePressed: () {}, @@ -140,7 +149,7 @@ class _WithSubtitleSection extends StatelessWidget { children: [ _ExampleCard( label: 'With file size', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.pdf), title: const Text('design_specs.pdf'), subtitle: Text('4.2 MB', style: subtitleStyle), @@ -149,7 +158,7 @@ class _WithSubtitleSection extends StatelessWidget { ), _ExampleCard( label: 'Without remove button', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.spreadsheet), title: const Text('budget_2025.xlsx'), subtitle: Text('1.8 MB', style: subtitleStyle), @@ -160,6 +169,35 @@ class _WithSubtitleSection extends StatelessWidget { } } +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final subtitleStyle = context.streamTextTheme.metadataDefault.copyWith(color: colorScheme.textTertiary); + + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'When a parent bounds the width, long file names ellipsize on a single line ' + 'rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 240', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: StreamMessageComposerFileAttachment( + fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.pdf), + title: const Text('quarterly_financial_results_summary.pdf'), + subtitle: Text('12.4 MB', style: subtitleStyle), + onRemovePressed: () {}, + ), + ), + ), + ], + ); + } +} + // ============================================================================= // Helper Widgets // ============================================================================= diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index c4834887..612bcb71 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -14,10 +14,15 @@ - `StreamEmojiPickerSheet.show` now resolves its background color and border radius from the ambient `StreamSheetTheme` so the picker visually matches other Stream-styled sheets by default. - `StreamLoadingSpinner` now renders a completion checkmark when progress reaches 100%. - `StreamCommandChip` is now tappable across its whole surface, not just the × icon. -- `StreamRemoveControl` now meets the 48 dp minimum tap target by default while keeping its 20 dp visible badge anchored to the top-end corner. Exposes `tapTargetSize`, `visualDensity`, and `semanticLabel`, and announces itself as a button to screen readers. +- `StreamRemoveControl` now meets the 48 dp minimum tap target by default while keeping its 20 dp visible badge anchored to the top-end corner. Exposes `tapTargetSize`, `visualDensity`, and `semanticLabel`, announces itself as a button to screen readers, and shows a click cursor on web/desktop when hovered. - Added `textAlignVertical` to `StreamTextInput` (and `StreamTextInputProps`) for controlling the vertical alignment of the text within the input. - Added `cursorColor`, `cursorErrorColor`, `cursorWidth`, `cursorHeight`, and `cursorRadius` to `StreamTextInputStyle` for customizing the text input cursor. `cursorErrorColor` is applied automatically when `helperState` is `StreamHelperState.error`. `StreamMessageComposerInputField` also honors these cursor properties from the theme. - Exported `DefaultStreamEmoji` so consumers can compose with or wrap the default emoji rendering when overriding via `StreamComponentFactory`. +- Added `StreamMessageComposerEditMessageAttachment`, a preview shown above the composer input while editing a message. +- Added `StreamMessageComposerUnsupportedAttachment`, a placeholder shown for attachments the client cannot render. +- Added a themed `thumbnail` slot to `StreamMessageComposerReplyAttachment` and `StreamMessageComposerEditMessageAttachment`, with matching `thumbnailSize`/`thumbnailShape`/`thumbnailSide` theme fields. +- Added per-widget themes for the message composer attachments: `StreamMessageComposerAttachmentTheme`, `StreamMessageComposerEditMessageAttachmentTheme`, `StreamMessageComposerFileAttachmentTheme`, `StreamMessageComposerLinkPreviewAttachmentTheme`, `StreamMessageComposerMediaAttachmentTheme`, `StreamMessageComposerReplyAttachmentTheme`, and `StreamMessageComposerUnsupportedAttachmentTheme`. All seven are wired into `StreamTheme` with matching `BuildContext` extensions. +- The composer attachment widgets (`StreamMessageComposerAttachment`, `StreamMessageComposerEditMessageAttachment`, `StreamMessageComposerFileAttachment`, `StreamMessageComposerLinkPreviewAttachment`, `StreamMessageComposerMediaAttachment`, `StreamMessageComposerReplyAttachment`, `StreamMessageComposerUnsupportedAttachment`) can now be customized via `StreamComponentFactory` — each exposes a matching builder slot taking its `*Props` configuration object. ### 🐞 Fixed @@ -29,6 +34,7 @@ - Updated `StreamReactionPicker` spacing to match the Figma specification. - Updated `StreamStepper` button style to match the Figma specification. - `StreamEmoji` now pins its primary `fontFamily` to the platform's native emoji font (Apple Color Emoji on iOS/macOS, Segoe UI Emoji on Windows, Noto Color Emoji elsewhere) so the existing per-platform `fontSize` correction lines up with the font that actually renders the glyph. `fontFamilyFallback` is unchanged. +- `StreamFileTypeIcon` now centers its SVG inside the icon's render box, so it stays centered when given larger constraints. ### 💥 Breaking Changes @@ -39,6 +45,14 @@ - Aligned `StreamButton` API with Flutter's built-in buttons: renamed `label` (`String?`) to required `child` (`Widget`), changed `icon`/`iconLeft`/`iconRight` from `IconData` to `Widget`, and renamed `onTap` to `onPressed`. `StreamButtonProps` mirrors the same renames. - Redesigned `StreamAppBar` with a slots-based API (`leading`/`title`/`subtitle`/`trailing`) and platform-aware auto-implied leading; replaces the previous Material `AppBar` wrapper. Adds `StreamAppBarStyle`, `StreamAppBarTheme`, and `StreamAppBarThemeData`. - `placeholder` on `StreamCoreMessageComposer`, `StreamMessageComposerInput`, and `StreamMessageComposerInputField` is now an optional `String?` (was `String` defaulting to `''`, and `required` on `StreamMessageComposerInputField`). +- Removed `StreamMessageTheme`, `StreamMessageThemeData`, and `StreamMessageStyle`; `MessageComposerReplyAttachment` and `MessageComposerLinkPreviewAttachment` now read colors directly from `StreamColorScheme`. +- Renamed `StreamMessageComposerAttachmentContainer` to `StreamMessageComposerAttachment` to mirror the existing `StreamMessageAttachment` naming. +- `StreamMessageComposerAttachment` now uses `shape: OutlinedBorder?` and `side: BorderSide?` (matching `StreamMessageAttachmentStyle`) in place of the previous `borderColor` / `borderRadius` fields and constructor params. Added a `style:` constructor param taking `StreamMessageComposerAttachmentThemeData?` for per-instance overrides; the old `backgroundColor` and `borderColor` widget params are gone. +- Added `style:` per-instance override params to `MessageComposerFileAttachment`, `MessageComposerMediaFileAttachment`, `MessageComposerLinkPreviewAttachment`, and `MessageComposerReplyAttachment`. Renamed `MessageComposerReplyAttachment.style` (the `ReplyStyle` enum slot) to `direction:` so `style:` is free for the theme-data override. +- Renamed the four composer attachment widgets to add the `Stream` prefix: `MessageComposerFileAttachment` → `StreamMessageComposerFileAttachment`, `MessageComposerMediaFileAttachment` → `StreamMessageComposerMediaAttachment` (also dropped the redundant "File" segment), `MessageComposerLinkPreviewAttachment` → `StreamMessageComposerLinkPreviewAttachment`, `MessageComposerReplyAttachment` → `StreamMessageComposerReplyAttachment`. +- Replaced `ReplyStyle` with `StreamReplyDirection` (same `incoming` / `outgoing` values) used by `StreamMessageComposerReplyAttachment.direction`. +- Renamed `StreamMessageComposerLinkPreviewAttachment.media` to `thumbnail`, and `StreamMessageComposerReplyAttachment.trailing` to `thumbnail`. +- Renamed `StreamMessageComposerLinkPreviewAttachmentThemeData` fields: `mediaSize` → `thumbnailSize`, `mediaShape` → `thumbnailShape`, `mediaSide` → `thumbnailSide`. ## 0.2.0 diff --git a/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart b/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart index dc48c9a7..7d68ea93 100644 --- a/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart @@ -305,6 +305,7 @@ class DefaultStreamFileTypeIcon extends StatelessWidget { final colorScheme = context.streamColorScheme; return Stack( + alignment: .center, clipBehavior: .none, children: [ SvgPicture.asset( diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart b/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart index ee6b06f0..95f30402 100644 --- a/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart +++ b/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -30,6 +31,19 @@ import 'package:flutter/rendering.dart'; /// are laid out within that extent. If no candidates exist, every child /// participates. /// +/// ## Bounded children +/// +/// By default children are measured with an unbounded cross-axis so they +/// can report their natural extent. Descendants that require a bounded +/// cross-axis to lay out (e.g. [ListView] with `shrinkWrap: true`, +/// [Wrap]) assert under that contract. +/// +/// If one or more children are wrapped in [StreamIntrinsicBoundedCrossAxis], +/// those children are measured under the incoming cross-axis ceiling +/// instead. The marker is independent of [StreamIntrinsicSizeCandidate] — +/// the marked child still participates in cross-axis resolution like any +/// other child. +/// /// {@tool snippet} /// /// A column that shrink-wraps to the widest child: @@ -63,12 +77,30 @@ import 'package:flutter/rendering.dart'; /// ``` /// {@end-tool} /// +/// {@tool snippet} +/// +/// If a child contains a descendant that requires a bounded cross-axis +/// (a shrink-wrapping viewport, a [Wrap]), wrap it in +/// [StreamIntrinsicBoundedCrossAxis]: +/// +/// ```dart +/// StreamIntrinsicColumn( +/// spacing: 8.0, +/// children: [ +/// StreamIntrinsicBoundedCrossAxis(child: MyContent()), // bounded +/// MyFooter(), // unbounded (default) +/// ], +/// ) +/// ``` +/// {@end-tool} +/// /// ## Layout algorithm /// /// 1. Lay out non-flex children with unbounded cross-axis to measure their -/// natural sizes. +/// natural sizes. Children wrapped in [StreamIntrinsicBoundedCrossAxis] +/// are measured under the parent's cross-axis ceiling instead. /// 2. Distribute remaining main-axis space to flexible children and lay -/// them out with unbounded cross-axis. +/// them out, with the same cross-axis treatment as step 1. /// 3. Resolve the cross-axis extent: /// - With candidates: `min(max(candidate extents), constraint)`. /// - Without candidates: `min(max(all extents), constraint)`. @@ -89,6 +121,8 @@ import 'package:flutter/rendering.dart'; /// * [StreamIntrinsicRow], for a horizontal variant. /// * [StreamIntrinsicSizeCandidate], to mark specific children as /// cross-axis candidates. +/// * [StreamIntrinsicBoundedCrossAxis], to give specific children +/// bounded cross-axis measurement. class StreamIntrinsicFlex extends MultiChildRenderObjectWidget { /// Creates an intrinsic flex layout. /// @@ -261,6 +295,8 @@ class StreamIntrinsicFlex extends MultiChildRenderObjectWidget { /// * [StreamIntrinsicFlex], the direction-agnostic variant. /// * [StreamIntrinsicSizeCandidate], to mark specific children as /// width candidates. +/// * [StreamIntrinsicBoundedCrossAxis], to give specific children a +/// bounded width. class StreamIntrinsicColumn extends StreamIntrinsicFlex { /// Creates a vertical intrinsic flex layout that shrink-wraps its width. const StreamIntrinsicColumn({ @@ -306,6 +342,8 @@ class StreamIntrinsicColumn extends StreamIntrinsicFlex { /// * [StreamIntrinsicFlex], the direction-agnostic variant. /// * [StreamIntrinsicSizeCandidate], to mark specific children as /// height candidates. +/// * [StreamIntrinsicBoundedCrossAxis], to give specific children a +/// bounded height. class StreamIntrinsicRow extends StreamIntrinsicFlex { /// Creates a horizontal intrinsic flex layout that shrink-wraps its height. const StreamIntrinsicRow({ @@ -380,9 +418,73 @@ class StreamIntrinsicSizeCandidate extends ParentDataWidget<_IntrinsicFlexParent Type get debugTypicalAncestorWidgetClass => StreamIntrinsicFlex; } +/// Marks a child of [StreamIntrinsicFlex] to be measured under the +/// parent's cross-axis ceiling instead of unbounded constraints. +/// +/// By default children are measured unbounded so [Align] and similar +/// widgets shrink-wrap. Descendants that require a bounded cross-axis +/// (e.g. [ListView] with `shrinkWrap: true`, [Wrap]) assert under that +/// contract — wrap them in this widget to opt into the bounded path. +/// +/// Has no effect when the parent's cross-axis is itself unbounded. +/// +/// This can be combined with [StreamIntrinsicSizeCandidate] on the same +/// child: +/// +/// ```dart +/// StreamIntrinsicColumn( +/// children: [ +/// StreamIntrinsicSizeCandidate( +/// child: StreamIntrinsicBoundedCrossAxis(child: MyContent()), +/// ), +/// ], +/// ) +/// ``` +/// +/// {@tool snippet} +/// +/// A column whose first child needs a bounded width to host a [ListView] +/// with `shrinkWrap: true`: +/// +/// ```dart +/// StreamIntrinsicColumn( +/// children: [ +/// StreamIntrinsicBoundedCrossAxis(child: BubbleWithListView()), +/// ReactionStrip(), +/// ], +/// ) +/// ``` +/// {@end-tool} +class StreamIntrinsicBoundedCrossAxis extends ParentDataWidget<_IntrinsicFlexParentData> { + /// Creates a widget that marks its child for bounded cross-axis + /// measurement. + const StreamIntrinsicBoundedCrossAxis({ + super.key, + required super.child, + }); + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData; + if (parentData is _IntrinsicFlexParentData && !parentData.boundedCrossAxis) { + parentData.boundedCrossAxis = true; + final renderParent = renderObject.parent; + if (renderParent is RenderObject) { + renderParent.markNeedsLayout(); + } + } + } + + @override + Type get debugTypicalAncestorWidgetClass => StreamIntrinsicFlex; +} + class _IntrinsicFlexParentData extends FlexParentData { // ignore: type_annotate_public_apis var isSizeCandidate = false; + + // ignore: type_annotate_public_apis + var boundedCrossAxis = false; } // ─── Render object ─── @@ -668,6 +770,79 @@ class _RenderStreamIntrinsicFlex extends RenderBox // ── Shared sizing (used by both performLayout and computeDryLayout) ── + // Lays out [child] with [constraints], augmenting the first + // FlutterError raised in the descendant subtree with a + // StreamIntrinsicBoundedCrossAxis hint and silencing the cascade. + // No-op when [constraints] are bounded on the cross-axis. + Size _layoutChildOrAddBoundedHint( + RenderBox child, + BoxConstraints constraints, + ChildLayouter layoutChild, + ) { + final crossBounded = _isVertical ? constraints.hasBoundedWidth : constraints.hasBoundedHeight; + if (crossBounded) return layoutChild(child, constraints); + + final originalOnError = FlutterError.onError; + var hintAttached = false; + FlutterError.onError = (details) { + if (!hintAttached) { + hintAttached = true; + originalOnError?.call(_appendBoundedHint(details)); + return; + } + // Cascade — every ancestor proxy in the failed subtree rethrows + // when accessing the missing size. Carries no new information; + // dropped entirely so it doesn't reach error reporters or the + // debug console. + }; + try { + return layoutChild(child, constraints); + } catch (_) { + // The descendant's failure escaped layoutChild (typically the + // AssertionError from accessing the failed child's `size`). The + // actionable error is already reported above; swallow this trailing + // throw and return a zero-size fallback so pass 2 can re-lay out + // every child under the resolved cross extent. Without this, parent + // data stays dirty and flushSemantics asserts later in the frame. + return Size.zero; + } finally { + FlutterError.onError = originalOnError; + } + } + + // Returns [details] with a StreamIntrinsicBoundedCrossAxis hint + // appended via FlutterErrorDetails.informationCollector. The hint is + // conditional — the unbounded constraint may originate here or from + // an intermediate widget below. + FlutterErrorDetails _appendBoundedHint(FlutterErrorDetails details) { + return FlutterErrorDetails( + exception: details.exception, + stack: details.stack, + library: details.library, + context: details.context, + silent: details.silent, + informationCollector: () => [ + ErrorHint( + 'A StreamIntrinsicFlex ancestor is measuring its children with ' + 'an unbounded cross-axis. Wrap the affected child of the ' + 'StreamIntrinsicFlex in StreamIntrinsicBoundedCrossAxis.', + ), + ErrorSpacer(), + describeForError('The StreamIntrinsicFlex'), + ErrorSpacer(), + ErrorDescription( + 'If wrapping does not resolve the error, an UnconstrainedBox ' + 'or OverflowBox below is forcing unbounded constraints; remove ' + 'or replace it instead.', + ), + ErrorSpacer(), + ErrorHint('See also: https://flutter.dev/unbounded-constraints'), + ErrorSpacer(), + if (details.informationCollector != null) ...details.informationCollector!(), + ], + ); + } + _LayoutSizes _computeSizes({ required BoxConstraints constraints, required ChildLayouter layoutChild, @@ -687,7 +862,8 @@ class _RenderStreamIntrinsicFlex extends RenderBox ))) : null; - // Pass 1a: lay out non-flex children with unbounded cross-axis. + // Pass 1a: lay out non-flex children with unbounded cross-axis, + // overridden per-child by StreamIntrinsicBoundedCrossAxis. var totalFlex = 0; var candidateExtent = 0.0; var allExtent = 0.0; @@ -706,8 +882,8 @@ class _RenderStreamIntrinsicFlex extends RenderBox totalFlex += flex; firstFlexChild ??= child; } else { - const childConstraints = BoxConstraints(); - final s = layoutChild(child, childConstraints); + final childConstraints = data.boundedCrossAxis ? _withCross(maxCross) : const BoxConstraints(); + final s = _layoutChildOrAddBoundedHint(child, childConstraints, layoutChild); final cross = _crossSize(s); inflexibleMainTotal += _mainSize(s); allExtent = math.max(allExtent, cross); @@ -732,7 +908,7 @@ class _RenderStreamIntrinsicFlex extends RenderBox final freeSpace = math.max(0, maxMainSize - inflexibleMainTotal - totalSpacing); final spacePerFlex = totalFlex > 0 ? freeSpace / totalFlex : 0.0; - // Pass 1c: lay out flex children with their allocation + unbounded cross. + // Pass 1c: lay out flex children with their allocation + cross-axis ceiling matching pass 1a. for (var fc = firstFlexChild; fc != null; fc = childAfter(fc)) { final data = fc.parentData! as _IntrinsicFlexParentData; final flex = _getFlex(fc); @@ -742,8 +918,9 @@ class _RenderStreamIntrinsicFlex extends RenderBox FlexFit.tight => maxChildExtent, FlexFit.loose => 0.0, }; - final childConstraints = _flexConstraints(minChildExtent, maxChildExtent, double.infinity); - final s = layoutChild(fc, childConstraints); + final crossCeiling = data.boundedCrossAxis ? maxCross : double.infinity; + final childConstraints = _flexConstraints(minChildExtent, maxChildExtent, crossCeiling); + final s = _layoutChildOrAddBoundedHint(fc, childConstraints, layoutChild); final cross = _crossSize(s); allExtent = math.max(allExtent, cross); if (data.isSizeCandidate) { diff --git a/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart b/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart index 282791e1..44dda7c4 100644 --- a/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart +++ b/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart @@ -75,22 +75,25 @@ class StreamRemoveControl extends StatelessWidget { child: GestureDetector( onTap: onPressed, behavior: .opaque, - child: StreamTapTargetPadding( - minSize: minSize, - alignment: AlignmentDirectional.topEnd, - child: Container( - width: _badgeSize.width, - height: _badgeSize.height, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.backgroundInverse, - border: Border.all(color: colorScheme.borderOnInverse, width: 2), - ), - child: Icon( - size: 16, - context.streamIcons.xmarkSmall, - color: colorScheme.textOnInverse, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: StreamTapTargetPadding( + minSize: minSize, + alignment: AlignmentDirectional.topEnd, + child: Container( + width: _badgeSize.width, + height: _badgeSize.height, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.backgroundInverse, + border: Border.all(color: colorScheme.borderOnInverse, width: 2), + ), + child: Icon( + size: 16, + context.streamIcons.xmarkSmall, + color: colorScheme.textOnInverse, + ), ), ), ), diff --git a/packages/stream_core_flutter/lib/src/components/message_composer.dart b/packages/stream_core_flutter/lib/src/components/message_composer.dart index 9a681bc7..e85a1ec4 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer.dart @@ -1,8 +1,17 @@ -export 'message_composer/attachment/message_composer_attachment_container.dart'; -export 'message_composer/attachment/message_composer_file_attachment.dart'; -export 'message_composer/attachment/message_composer_link_preview_attachment.dart'; -export 'message_composer/attachment/message_composer_media_file_attachment.dart'; -export 'message_composer/attachment/message_composer_reply_attachment.dart'; +export 'message_composer/attachment/stream_message_composer_attachment.dart' + hide DefaultStreamMessageComposerAttachment; +export 'message_composer/attachment/stream_message_composer_edit_message_attachment.dart' + hide DefaultStreamMessageComposerEditMessageAttachment; +export 'message_composer/attachment/stream_message_composer_file_attachment.dart' + hide DefaultStreamMessageComposerFileAttachment; +export 'message_composer/attachment/stream_message_composer_link_preview_attachment.dart' + hide DefaultStreamMessageComposerLinkPreviewAttachment; +export 'message_composer/attachment/stream_message_composer_media_attachment.dart' + hide DefaultStreamMessageComposerMediaAttachment; +export 'message_composer/attachment/stream_message_composer_reply_attachment.dart' + hide DefaultStreamMessageComposerReplyAttachment; +export 'message_composer/attachment/stream_message_composer_unsupported_attachment.dart' + hide DefaultStreamMessageComposerUnsupportedAttachment; export 'message_composer/message_composer.dart'; export 'message_composer/message_composer_input.dart'; export 'message_composer/message_composer_input_trailing.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart deleted file mode 100644 index a4dde0c2..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme.dart'; -import '../../controls/stream_remove_control.dart'; - -/// A styled container that wraps message composer attachment content with a -/// themed background, shape, and border. -/// -/// Built-in defaults provide a rounded shape, background color, and border -/// matching the design system tokens. -/// -/// An optional [onRemovePressed] callback adds a [StreamRemoveControl] overlay. -/// -/// {@tool snippet} -/// -/// Basic usage relying on defaults: -/// -/// ```dart -/// StreamMessageComposerAttachmentContainer( -/// onRemovePressed: () => removeAttachment(), -/// child: MyAttachmentContent(), -/// ) -/// ``` -/// {@end-tool} -/// -/// {@tool snippet} -/// -/// With custom colors: -/// -/// ```dart -/// StreamMessageComposerAttachmentContainer( -/// backgroundColor: Colors.blue.shade50, -/// borderColor: Colors.blue.shade200, -/// child: MyAttachmentContent(), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamRemoveControl], the remove button shown when [onRemovePressed] -/// is provided. -/// * [StreamMessageAttachment], the analogous container for message-list -/// attachments. -class StreamMessageComposerAttachmentContainer extends StatelessWidget { - /// Creates a composer attachment container. - const StreamMessageComposerAttachmentContainer({ - super.key, - required this.child, - this.onRemovePressed, - this.backgroundColor, - this.borderColor, - }); - - /// The content widget displayed inside the container. - final Widget child; - - /// Called when the remove button is tapped. - /// - /// When non-null, a [StreamRemoveControl] overlay appears at the - /// top-right corner of the container. - final VoidCallback? onRemovePressed; - - /// The background fill color of the container. - /// - /// Falls back to [StreamColorScheme.backgroundElevation1]. - final Color? backgroundColor; - - /// The border color of the container. - /// - /// When non-null, a 1px border is drawn with this color. When null, the - /// default border from [StreamColorScheme.borderDefault] is used. - final Color? borderColor; - - @override - Widget build(BuildContext context) { - final radius = context.streamRadius; - final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - - final effectiveBorderColor = borderColor ?? colorScheme.borderDefault; - final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundElevation1; - - final container = Container( - clipBehavior: .hardEdge, - margin: .all(spacing.xxs), - decoration: BoxDecoration( - borderRadius: .all(radius.lg), - color: effectiveBackgroundColor, - ), - foregroundDecoration: BoxDecoration( - borderRadius: .all(radius.lg), - border: .all( - color: effectiveBorderColor, - strokeAlign: BorderSide.strokeAlignInside, - ), - ), - child: child, - ); - - if (onRemovePressed case final onPressed?) { - return Stack( - clipBehavior: .none, - children: [ - container, - PositionedDirectional( - top: 0, - end: 0, - child: StreamRemoveControl(onPressed: onPressed), - ), - ], - ); - } - - return container; - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart deleted file mode 100644 index 7138002b..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerFileAttachment extends StatelessWidget { - const MessageComposerFileAttachment({ - super.key, - required this.title, - this.subtitle, - this.fileTypeIcon, - this.onRemovePressed, - }); - - final Widget title; - final Widget? subtitle; - final StreamFileTypeIcon? fileTypeIcon; - final VoidCallback? onRemovePressed; - - @override - Widget build(BuildContext context) { - final spacing = context.streamSpacing; - - final textTheme = context.streamTextTheme; - final colorScheme = context.streamColorScheme; - - final titleStyle = textTheme.metadataEmphasis.copyWith(color: colorScheme.textPrimary); - final subtitleStyle = textTheme.metadataDefault.copyWith(color: colorScheme.textSecondary); - - final effectiveTitle = DefaultTextStyle.merge(style: titleStyle, maxLines: 1, overflow: .ellipsis, child: title); - - Widget? effectiveSubtitle; - if (subtitle case final title?) { - effectiveSubtitle = DefaultTextStyle.merge(style: subtitleStyle, maxLines: 1, overflow: .ellipsis, child: title); - } - - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - child: Padding( - padding: .directional( - start: spacing.md, - end: spacing.sm, - top: spacing.md, - bottom: spacing.md, - ), - child: Row( - spacing: spacing.sm, - children: [ - ?fileTypeIcon, - Expanded( - child: Column( - spacing: spacing.xxs, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - effectiveTitle, - ?effectiveSubtitle, - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart deleted file mode 100644 index 3ec5a175..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerLinkPreviewAttachment extends StatelessWidget { - const MessageComposerLinkPreviewAttachment({ - super.key, - this.title, - this.subtitle, - this.url, - this.image, - this.onRemovePressed, - }); - - final String? title; - final String? subtitle; - final String? url; - final ImageProvider? image; - final VoidCallback? onRemovePressed; - - @override - Widget build(BuildContext context) { - final messageTheme = context.streamMessageTheme.mergeWithDefaults(context); - final backgroundColor = messageTheme.outgoing?.backgroundColor; - final textColor = messageTheme.outgoing?.textColor; - - final titleStyle = context.streamTextTheme.metadataEmphasis.copyWith(color: textColor); - final bodyStyle = context.streamTextTheme.metadataDefault.copyWith(color: textColor); - - final spacing = context.streamSpacing; - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - backgroundColor: backgroundColor, - borderColor: StreamColors.transparent, - child: Padding( - padding: .directional( - start: spacing.xs, - end: spacing.sm, - top: spacing.xs, - bottom: spacing.xs, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (image != null) ...[ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.md), - image: DecorationImage(image: image!, fit: BoxFit.cover), - ), - ), - SizedBox(width: spacing.xs), - ], - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title case final title?) - Text( - title, - style: titleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (subtitle case final subtitle?) - Text( - subtitle, - style: bodyStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (url case final url?) - Row( - children: [ - Icon(context.streamIcons.link, size: 12), - SizedBox(width: spacing.xxs), - Expanded( - child: Text( - url, - style: bodyStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart deleted file mode 100644 index 90c29211..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerMediaFileAttachment extends StatelessWidget { - const MessageComposerMediaFileAttachment({ - super.key, - required this.child, - this.onRemovePressed, - this.mediaBadge, - }); - - MessageComposerMediaFileAttachment.image({ - super.key, - required ImageProvider image, - required this.onRemovePressed, - ImageFrameBuilder? frameBuilder, - ImageErrorWidgetBuilder? errorBuilder, - this.mediaBadge, - }) : child = Image( - image: image, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - fit: BoxFit.cover, - ); - - final Widget child; - final Widget? mediaBadge; - final VoidCallback? onRemovePressed; - - @override - Widget build(BuildContext context) { - final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - borderColor: colorScheme.borderOpacitySubtle, - child: SizedBox.square( - dimension: 72, - child: Stack( - children: [ - child, - if (mediaBadge case final badge?) - PositionedDirectional( - start: spacing.xxs, - bottom: spacing.xxs, - child: badge, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart deleted file mode 100644 index 79f723e2..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerReplyAttachment extends StatelessWidget { - const MessageComposerReplyAttachment({ - super.key, - required this.title, - required this.subtitle, - this.trailing, - this.onRemovePressed, - this.style = ReplyStyle.incoming, - }); - - final Widget title; - final Widget subtitle; - final Widget? trailing; - final VoidCallback? onRemovePressed; - final ReplyStyle style; - - @override - Widget build(BuildContext context) { - final messageTheme = context.streamMessageTheme.mergeWithDefaults(context); - final messageStyle = switch (style) { - ReplyStyle.incoming => messageTheme.incoming, - ReplyStyle.outgoing => messageTheme.outgoing, - }; - - final indicatorColor = messageStyle?.replyIndicatorColor; - final backgroundColor = messageStyle?.backgroundColor; - final textColor = messageStyle?.textColor; - - final spacing = context.streamSpacing; - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - backgroundColor: backgroundColor, - borderColor: StreamColors.transparent, - child: Padding( - padding: .all(spacing.xs), - child: Row( - spacing: spacing.xs, - children: [ - Container( - margin: const EdgeInsets.only(top: 2, bottom: 2), - color: indicatorColor, - child: const SizedBox(width: 2, height: 36), - ), - Expanded( - child: Column( - spacing: spacing.xxxs, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - DefaultTextStyle.merge( - style: context.streamTextTheme.metadataEmphasis.copyWith(color: textColor), - child: title, - ), - DefaultTextStyle.merge( - style: context.streamTextTheme.metadataDefault.copyWith(color: textColor), - child: subtitle, - ), - ], - ), - ), - ?trailing, - ], - ), - ), - ); - } -} - -enum ReplyStyle { - incoming, - outgoing, -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart new file mode 100644 index 00000000..e68e60c4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import '../../../theme.dart'; +import '../../controls/stream_remove_control.dart'; + +/// A styled container that wraps composer attachment content with a themed background, shape, +/// and border. +/// +/// [StreamMessageComposerAttachment] sizes itself to its [child], paints a rounded surface +/// behind it, and clips the [child] to that shape. An outlined border is composed onto the +/// shape. The container also adds an outer margin so adjacent attachments in a row don't +/// touch. +/// +/// When [onRemovePressed] is non-null, a circular [StreamRemoveControl] is overlaid at the +/// top-end corner, slightly outside the shape so it visually attaches to the edge. +/// +/// {@tool snippet} +/// +/// Basic usage — a rounded card with a subtle 1px border: +/// +/// ```dart +/// StreamMessageComposerAttachment( +/// onRemovePressed: () => removeAttachment(), +/// child: MyAttachmentContent(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With explicit style overrides: +/// +/// ```dart +/// StreamMessageComposerAttachment( +/// style: StreamMessageComposerAttachmentThemeData( +/// backgroundColor: Colors.blue.shade50, +/// side: BorderSide(color: Colors.blue.shade200), +/// ), +/// child: MyAttachmentContent(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerAttachment] uses [StreamMessageComposerAttachmentThemeData] for +/// default styling — background color, shape, border side, and outer padding. Per-instance +/// [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerAttachmentTheme], for customizing attachment containers globally. +/// * [StreamRemoveControl], the remove button shown when [onRemovePressed] is provided. +/// * [StreamMessageAttachment], the analogous container for message-list attachments. +/// * [DefaultStreamMessageComposerAttachment], the default visual implementation. +class StreamMessageComposerAttachment extends StatelessWidget { + /// Creates a composer attachment container. + StreamMessageComposerAttachment({ + super.key, + required Widget child, + VoidCallback? onRemovePressed, + StreamMessageComposerAttachmentThemeData? style, + }) : props = .new( + child: child, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this attachment container. + final StreamMessageComposerAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerAttachment]. +/// +/// This class holds all the configuration options for an attachment container, allowing them +/// to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerAttachment], the default implementation. +class StreamMessageComposerAttachmentProps { + /// Creates properties for an attachment container. + const StreamMessageComposerAttachmentProps({ + required this.child, + this.onRemovePressed, + this.style, + }); + + /// The content displayed inside the container. + /// + /// Clipped to the container's rounded shape. + final Widget child; + + /// Called when the remove button is tapped. + /// + /// When non-null, a [StreamRemoveControl] is overlaid at the top-end corner of the + /// container. When null, no remove control is shown. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerAttachmentTheme], + /// then to built-in defaults. + final StreamMessageComposerAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerAttachment]. +/// +/// Renders the container with theming support from [StreamMessageComposerAttachmentTheme]. +/// It is used as the default factory implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerAttachment], the public API widget. +/// * [StreamMessageComposerAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerAttachment extends StatelessWidget { + /// Creates a default attachment container with the given [props]. + const DefaultStreamMessageComposerAttachment({super.key, required this.props}); + + /// The properties that configure this attachment container. + final StreamMessageComposerAttachmentProps props; + + @override + Widget build(BuildContext context) { + final theme = context.streamMessageComposerAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerAttachmentDefaults(context); + + final effectiveSide = theme.side ?? defaults.side; + final effectiveShape = (theme.shape ?? defaults.shape).copyWith(side: effectiveSide); + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectivePadding = theme.padding ?? defaults.padding; + + final container = Padding( + padding: effectivePadding, + child: Material( + clipBehavior: .hardEdge, + shape: effectiveShape, + color: effectiveBackgroundColor, + child: props.child, + ), + ); + + if (props.onRemovePressed case final onPressed?) { + return Stack( + clipBehavior: .none, + children: [ + container, + PositionedDirectional( + top: 0, + end: 0, + child: StreamRemoveControl(onPressed: onPressed), + ), + ], + ); + } + + return container; + } +} + +// Default theme values for [StreamMessageComposerAttachment]. +// +// Used when no explicit value is provided via [style] or +// [StreamMessageComposerAttachmentThemeData]. +class _StreamMessageComposerAttachmentDefaults extends StreamMessageComposerAttachmentThemeData { + _StreamMessageComposerAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _radius = _context.streamRadius; + late final _spacing = _context.streamSpacing; + late final _colorScheme = _context.streamColorScheme; + + @override + Color get backgroundColor => _colorScheme.backgroundElevation1; + + @override + OutlinedBorder get shape => RoundedSuperellipseBorder(borderRadius: .all(_radius.lg)); + + @override + BorderSide get side => BorderSide(color: _colorScheme.borderDefault); + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xxs); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart new file mode 100644 index 00000000..671cac20 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(minHeight: 56); + +const _kIndicatorWidth = 2.0; +const _kIndicatorVerticalMargin = 2.0; + +/// A composer attachment that previews the message currently being edited. +/// +/// [StreamMessageComposerEditMessageAttachment] displays a leading indicator bar, a [title] +/// (typically a localized "Edit message" label), a [subtitle] (the body of the message being +/// edited), and an optional trailing [thumbnail] of the attached media. Both labels render on +/// a single line and ellipsize on overflow. +/// +/// The preview fills the parent's width and is at least 56 tall, growing vertically to fit +/// longer content. +/// +/// {@tool snippet} +/// +/// An edit-message preview: +/// +/// ```dart +/// StreamMessageComposerEditMessageAttachment( +/// title: Text('Edit message'), +/// subtitle: Text('See you at 9!'), +/// onRemovePressed: () => cancelEdit(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerEditMessageAttachment] uses +/// [StreamMessageComposerEditMessageAttachmentThemeData] for default styling. Per-instance +/// [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachmentTheme], for customizing edit previews +/// globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [DefaultStreamMessageComposerEditMessageAttachment], the default visual implementation. +class StreamMessageComposerEditMessageAttachment extends StatelessWidget { + /// Creates an edit-message preview attachment. + StreamMessageComposerEditMessageAttachment({ + super.key, + required Widget title, + required Widget subtitle, + Widget? thumbnail, + VoidCallback? onRemovePressed, + StreamMessageComposerEditMessageAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + thumbnail: thumbnail, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this edit-message preview. + final StreamMessageComposerEditMessageAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerEditMessageAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerEditMessageAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerEditMessageAttachment]. +/// +/// This class holds all the configuration options for an edit-message preview, allowing them +/// to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerEditMessageAttachment], the default implementation. +class StreamMessageComposerEditMessageAttachmentProps { + /// Creates properties for an edit-message preview attachment. + const StreamMessageComposerEditMessageAttachmentProps({ + required this.title, + required this.subtitle, + this.thumbnail, + this.onRemovePressed, + this.style, + }); + + /// The primary label shown on the first line. + /// + /// Typically a [Text] showing a localized "Edit message" string. Renders on a single line + /// and ellipsizes on overflow. + final Widget title; + + /// The secondary label shown below [title]. + /// + /// Typically a [Text] previewing the body of the message being edited. Renders on a single + /// line and ellipsizes on overflow. + final Widget subtitle; + + /// An optional thumbnail of the attached media, rendered at the end of the preview row. + /// + /// Sized and shaped via the inherited theme (default 40×40 with rounded corners). The + /// caller-provided widget is responsible for filling the thumbnail bounds — typically an + /// [Image] with [BoxFit.cover]. When null, the title/subtitle column expands to the full + /// width of the row. + final Widget? thumbnail; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited + /// [StreamMessageComposerEditMessageAttachmentTheme], then to built-in defaults. + final StreamMessageComposerEditMessageAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerEditMessageAttachment]. +/// +/// Renders the edit-message preview with theming support from +/// [StreamMessageComposerEditMessageAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachment], the public API widget. +/// * [StreamMessageComposerEditMessageAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerEditMessageAttachment extends StatelessWidget { + /// Creates a default edit-message preview with the given [props]. + const DefaultStreamMessageComposerEditMessageAttachment({super.key, required this.props}); + + /// The properties that configure this edit-message preview. + final StreamMessageComposerEditMessageAttachmentProps props; + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerEditMessageAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerEditMessageAttachmentDefaults(context); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveIndicatorColor = theme.indicatorColor ?? defaults.indicatorColor; + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.title, + ); + + final effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.subtitle, + ); + + Widget? effectiveThumbnail; + if (props.thumbnail case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + backgroundColor: effectiveBackgroundColor, + side: BorderSide.none, + ), + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: IntrinsicHeight( + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + VerticalDivider( + width: _kIndicatorWidth, + thickness: _kIndicatorWidth, + indent: _kIndicatorVerticalMargin, + endIndent: _kIndicatorVerticalMargin, + radius: BorderRadius.all(radius.max), + color: effectiveIndicatorColor, + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [effectiveTitle, effectiveSubtitle], + ), + ), + ?effectiveThumbnail, + ], + ), + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerEditMessageAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerEditMessageAttachmentThemeData]. +class _StreamMessageComposerEditMessageAttachmentDefaults extends StreamMessageComposerEditMessageAttachmentThemeData { + _StreamMessageComposerEditMessageAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + @override + Color get backgroundColor => _colorScheme.brand.shade100; + + @override + Color get indicatorColor => _colorScheme.brand.shade400; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _colorScheme.textPrimary); + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xs); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: .all(_context.streamRadius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart new file mode 100644 index 00000000..0c48d84b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart @@ -0,0 +1,220 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(maxWidth: 260, maxHeight: 260, minHeight: 72); + +/// A composer attachment row that previews a file by name and metadata. +/// +/// [StreamMessageComposerFileAttachment] displays an optional leading [fileTypeIcon], a +/// [title] (the file name), and an optional [subtitle] (typically size or extension). It's +/// used for non-media files — documents, archives, etc. — that aren't shown as a thumbnail. +/// Both labels render on a single line and ellipsize on overflow. +/// +/// The row is at most 260 wide with a minimum height of 72. It shrinks to fit when a parent +/// bounds the width below 260, and the height grows to accommodate taller content (up to 260). +/// +/// {@tool snippet} +/// +/// A file attachment with name and size: +/// +/// ```dart +/// StreamMessageComposerFileAttachment( +/// title: Text('report.pdf'), +/// subtitle: Text('1.2 MB'), +/// fileTypeIcon: StreamFileTypeIcon.pdf(), +/// onRemovePressed: () => removeAttachment(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerFileAttachment] uses [StreamMessageComposerFileAttachmentThemeData] +/// for default styling. Per-instance [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachmentTheme], for customizing file attachments globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [StreamFileTypeIcon], the icon shown in the [fileTypeIcon] slot. +/// * [DefaultStreamMessageComposerFileAttachment], the default visual implementation. +class StreamMessageComposerFileAttachment extends StatelessWidget { + /// Creates a file attachment row. + StreamMessageComposerFileAttachment({ + super.key, + required Widget title, + Widget? subtitle, + StreamFileTypeIcon? fileTypeIcon, + VoidCallback? onRemovePressed, + StreamMessageComposerFileAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + fileTypeIcon: fileTypeIcon, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this file attachment. + final StreamMessageComposerFileAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerFileAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerFileAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerFileAttachment]. +/// +/// This class holds all the configuration options for a file attachment, allowing them to be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerFileAttachment], the default implementation. +class StreamMessageComposerFileAttachmentProps { + /// Creates properties for a file attachment. + const StreamMessageComposerFileAttachmentProps({ + required this.title, + this.subtitle, + this.fileTypeIcon, + this.onRemovePressed, + this.style, + }); + + /// The primary label shown on the first line. + /// + /// Typically a [Text] showing the file name. Renders on a single line and ellipsizes on + /// overflow. + final Widget title; + + /// An optional secondary label shown below [title]. + /// + /// Typically a [Text] showing the file size or extension. Renders on a single line and + /// ellipsizes on overflow. The row collapses to a single line when null. + final Widget? subtitle; + + /// An optional leading icon representing the file type. + /// + /// When null, the row begins with the title/subtitle column flush to the start padding. + final StreamFileTypeIcon? fileTypeIcon; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerFileAttachmentTheme], + /// then to built-in defaults. + final StreamMessageComposerFileAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerFileAttachment]. +/// +/// Renders the file attachment with theming support from +/// [StreamMessageComposerFileAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachment], the public API widget. +/// * [StreamMessageComposerFileAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerFileAttachment extends StatelessWidget { + /// Creates a default file attachment with the given [props]. + const DefaultStreamMessageComposerFileAttachment({super.key, required this.props}); + + /// The properties that configure this file attachment. + final StreamMessageComposerFileAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerFileAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerFileAttachmentDefaults(context); + + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveSpacing = theme.spacing ?? defaults.spacing; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: .ellipsis, + child: props.title, + ); + + Widget? effectiveSubtitle; + if (props.subtitle case final subtitle?) { + effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: .ellipsis, + child: subtitle, + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + children: [ + ?props.fileTypeIcon, + Expanded( + child: Column( + spacing: spacing.xxs, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [effectiveTitle, ?effectiveSubtitle], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerFileAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerFileAttachmentThemeData]. +class _StreamMessageComposerFileAttachmentDefaults extends StreamMessageComposerFileAttachmentThemeData { + _StreamMessageComposerFileAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _colorScheme.textSecondary); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.only( + start: _spacing.md, + end: _spacing.sm, + top: _spacing.md, + bottom: _spacing.md, + ); + + @override + double get spacing => _spacing.sm; +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart new file mode 100644 index 00000000..62630d45 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(minWidth: 290); + +/// A composer attachment that previews a link with optional thumbnail, title, subtitle, and +/// caption. +/// +/// [StreamMessageComposerLinkPreviewAttachment] displays an optional leading [thumbnail] +/// (typically a favicon or hero image), a [title] (the page title), a [subtitle] (the page +/// description), and a [caption] (typically the URL with a leading link icon). Each text +/// field renders on a single line and ellipsizes on overflow; null fields are omitted. +/// +/// The preview has a minimum width of 290 and grows to fit longer content. It shrinks to fit +/// when a parent bounds the width below 290. Height adapts to the content. +/// +/// {@tool snippet} +/// +/// A link preview with title, subtitle, and a caption with a leading link icon: +/// +/// ```dart +/// StreamMessageComposerLinkPreviewAttachment( +/// title: Text('Stream Chat'), +/// subtitle: Text('Build in-app chat in days, not months.'), +/// caption: Row( +/// spacing: 4, +/// children: [Icon(Icons.link, size: 12), Text('getstream.io')], +/// ), +/// thumbnail: Image(image: NetworkImage(faviconUrl), fit: BoxFit.cover), +/// onRemovePressed: () => removePreview(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerLinkPreviewAttachment] uses +/// [StreamMessageComposerLinkPreviewAttachmentThemeData] for default styling, including the +/// thumbnail's size, shape, and border. Per-instance [style] takes precedence over the +/// inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachmentTheme], for customizing link previews +/// globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [DefaultStreamMessageComposerLinkPreviewAttachment], the default visual implementation. +class StreamMessageComposerLinkPreviewAttachment extends StatelessWidget { + /// Creates a link preview attachment. + StreamMessageComposerLinkPreviewAttachment({ + super.key, + Widget? title, + Widget? subtitle, + Widget? caption, + Widget? thumbnail, + VoidCallback? onRemovePressed, + StreamMessageComposerLinkPreviewAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + caption: caption, + thumbnail: thumbnail, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this link preview. + final StreamMessageComposerLinkPreviewAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerLinkPreviewAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerLinkPreviewAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerLinkPreviewAttachment]. +/// +/// This class holds all the configuration options for a link preview, allowing them to be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerLinkPreviewAttachment], the default implementation. +class StreamMessageComposerLinkPreviewAttachmentProps { + /// Creates properties for a link preview attachment. + const StreamMessageComposerLinkPreviewAttachmentProps({ + this.title, + this.subtitle, + this.caption, + this.thumbnail, + this.onRemovePressed, + this.style, + }); + + /// The title displayed on the first line. + /// + /// Typically a [Text] showing the page title. Renders on a single line and ellipsizes on + /// overflow. Omitted when null. + final Widget? title; + + /// The subtitle displayed below [title]. + /// + /// Typically a [Text] showing the page description. Renders on a single line and + /// ellipsizes on overflow. Omitted when null. + final Widget? subtitle; + + /// The caption displayed at the bottom of the preview. + /// + /// Typically the URL with a leading link icon (callers compose the icon + text themselves, + /// e.g. via a [Row]). Renders on a single line and ellipsizes on overflow. Omitted when + /// null. + final Widget? caption; + + /// An optional leading thumbnail. + /// + /// Typically an [Image] showing a favicon or hero image. When non-null, a square thumbnail + /// is placed at the start of the row and clipped to the thumbnail border radius; when null, + /// the text column is flush with the start padding. The widget is responsible for filling + /// the thumbnail bounds (typically [Image] with [BoxFit.cover]). + final Widget? thumbnail; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited + /// [StreamMessageComposerLinkPreviewAttachmentTheme], then to built-in defaults. + final StreamMessageComposerLinkPreviewAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerLinkPreviewAttachment]. +/// +/// Renders the link preview with theming support from +/// [StreamMessageComposerLinkPreviewAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachment], the public API widget. +/// * [StreamMessageComposerLinkPreviewAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerLinkPreviewAttachment extends StatelessWidget { + /// Creates a default link preview with the given [props]. + const DefaultStreamMessageComposerLinkPreviewAttachment({super.key, required this.props}); + + /// The properties that configure this link preview. + final StreamMessageComposerLinkPreviewAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerLinkPreviewAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerLinkPreviewAttachmentDefaults(context); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + Widget? effectiveThumbnail; + if (props.thumbnail case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + Widget? effectiveTitle; + if (props.title case final title?) { + effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: title, + ); + } + + Widget? effectiveSubtitle; + if (props.subtitle case final subtitle?) { + effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: subtitle, + ); + } + + Widget? effectiveCaption; + if (props.caption case final caption?) { + effectiveCaption = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: caption, + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + backgroundColor: effectiveBackgroundColor, + side: BorderSide.none, + ), + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: spacing.xs, + children: [ + ?effectiveThumbnail, + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + crossAxisAlignment: .start, + children: [?effectiveTitle, ?effectiveSubtitle, ?effectiveCaption], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerLinkPreviewAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerLinkPreviewAttachmentThemeData]. +class _StreamMessageComposerLinkPreviewAttachmentDefaults extends StreamMessageComposerLinkPreviewAttachmentThemeData { + _StreamMessageComposerLinkPreviewAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _radius = _context.streamRadius; + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + late final Color _textColor = _colorScheme.brand.shade900; + + @override + Color get backgroundColor => _colorScheme.brand.shade100; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.only( + start: _spacing.xs, + end: _spacing.sm, + top: _spacing.xs, + bottom: _spacing.xs, + ); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: BorderRadius.all(_radius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart new file mode 100644 index 00000000..c7db6ff2 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart @@ -0,0 +1,187 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +/// A composer attachment that displays a fixed-size thumbnail for an image or video. +/// +/// [StreamMessageComposerMediaAttachment] displays a [child] (typically an image or video +/// thumbnail) inside a square frame (default 72×72), with an optional [mediaBadge] overlaid +/// at the bottom-start corner — used to indicate video duration or media type. The [child] +/// is given tight constraints equal to the thumbnail bounds, so an [Image] with +/// [BoxFit.cover] fills automatically. +/// +/// {@tool snippet} +/// +/// An image media attachment: +/// +/// ```dart +/// StreamMessageComposerMediaAttachment( +/// onRemovePressed: () => removeAttachment(), +/// child: Image(image: NetworkImage(url), fit: BoxFit.cover), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// A video thumbnail with a duration badge: +/// +/// ```dart +/// StreamMessageComposerMediaAttachment( +/// mediaBadge: StreamMediaBadge( +/// type: MediaBadgeType.video, +/// duration: Duration(minutes: 1, seconds: 42), +/// ), +/// onRemovePressed: () => removeAttachment(), +/// child: Image(image: NetworkImage(thumbnailUrl), fit: BoxFit.cover), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerMediaAttachment] uses [StreamMessageComposerMediaAttachmentThemeData] +/// for default styling, including the border color and the thumbnail dimensions. +/// Per-instance [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachmentTheme], for customizing media attachments globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [StreamMediaBadge], the badge typically passed as [mediaBadge]. +/// * [DefaultStreamMessageComposerMediaAttachment], the default visual implementation. +class StreamMessageComposerMediaAttachment extends StatelessWidget { + /// Creates a media attachment with a custom [child]. + StreamMessageComposerMediaAttachment({ + super.key, + required Widget child, + VoidCallback? onRemovePressed, + Widget? mediaBadge, + StreamMessageComposerMediaAttachmentThemeData? style, + }) : props = .new( + child: child, + onRemovePressed: onRemovePressed, + mediaBadge: mediaBadge, + style: style, + ); + + /// The properties that configure this media attachment. + final StreamMessageComposerMediaAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerMediaAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerMediaAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerMediaAttachment]. +/// +/// This class holds all the configuration options for a media attachment, allowing them to +/// be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerMediaAttachment], the default implementation. +class StreamMessageComposerMediaAttachmentProps { + /// Creates properties for a media attachment. + const StreamMessageComposerMediaAttachmentProps({ + required this.child, + this.onRemovePressed, + this.mediaBadge, + this.style, + }); + + /// The thumbnail content, clipped to the surrounding container's rounded shape. + /// + /// Given tight constraints equal to the thumbnail bounds (default 72×72), so an [Image] + /// with [BoxFit.cover] fills automatically without any extra wrapper. + final Widget child; + + /// An optional badge overlaid at the bottom-start corner of the thumbnail. + /// + /// Typically a [StreamMediaBadge] used to indicate video duration or media type. + final Widget? mediaBadge; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerMediaAttachmentTheme], + /// then to built-in defaults. + final StreamMessageComposerMediaAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerMediaAttachment]. +/// +/// Renders the media attachment with theming support from +/// [StreamMessageComposerMediaAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachment], the public API widget. +/// * [StreamMessageComposerMediaAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerMediaAttachment extends StatelessWidget { + /// Creates a default media attachment with the given [props]. + const DefaultStreamMessageComposerMediaAttachment({super.key, required this.props}); + + /// The properties that configure this media attachment. + final StreamMessageComposerMediaAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerMediaAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerMediaAttachmentDefaults(context); + + final effectiveBorderColor = theme.borderColor ?? defaults.borderColor; + final effectiveSize = theme.size ?? defaults.size; + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + side: BorderSide(color: effectiveBorderColor), + ), + child: SizedBox.fromSize( + size: effectiveSize, + child: Stack( + fit: .expand, + children: [ + props.child, + if (props.mediaBadge case final badge?) + PositionedDirectional( + start: spacing.xxs, + bottom: spacing.xxs, + child: badge, + ), + ], + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerMediaAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerMediaAttachmentThemeData]. +class _StreamMessageComposerMediaAttachmentDefaults extends StreamMessageComposerMediaAttachmentThemeData { + _StreamMessageComposerMediaAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + + @override + Color get borderColor => _colorScheme.borderOpacitySubtle; + + @override + Size get size => const Size.square(72); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart new file mode 100644 index 00000000..fa40bfa3 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(minHeight: 56); + +const _kIndicatorWidth = 2.0; +const _kIndicatorVerticalMargin = 2.0; + +/// The direction of the message being quoted by a reply preview. +/// +/// Drives the direction-aware default colors of [StreamMessageComposerReplyAttachment] when the +/// corresponding theme fields are not provided. +enum StreamReplyDirection { + /// The quoted message was received from another user. + incoming, + + /// The quoted message was sent by the current user. + outgoing, +} + +/// A composer attachment that previews the message being replied to. +/// +/// [StreamMessageComposerReplyAttachment] displays a leading indicator bar, a [title] +/// (typically the quoted user's name), a [subtitle] (a preview of the quoted message body), +/// and an optional trailing [thumbnail] of the quoted attachment. Both labels render on a +/// single line and ellipsize on overflow. +/// +/// [direction] selects direction-aware default colors: incoming previews use a neutral +/// surface tint with a chrome indicator, outgoing previews use a brand-tinted background with +/// a brand indicator. +/// +/// The preview fills the parent's width and is at least 56 tall, growing vertically to fit +/// longer content. +/// +/// {@tool snippet} +/// +/// A reply preview for an outgoing message: +/// +/// ```dart +/// StreamMessageComposerReplyAttachment( +/// title: Text('Reply to John Doe'), +/// subtitle: Text('We had a great time during our holiday.'), +/// direction: StreamReplyDirection.outgoing, +/// onRemovePressed: () => cancelReply(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerReplyAttachment] uses +/// [StreamMessageComposerReplyAttachmentThemeData] for default styling, falling back to +/// direction-aware defaults selected by [direction]. Per-instance [style] takes precedence +/// over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachmentTheme], for customizing reply previews globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [StreamReplyDirection], which selects direction-aware defaults. +/// * [DefaultStreamMessageComposerReplyAttachment], the default visual implementation. +class StreamMessageComposerReplyAttachment extends StatelessWidget { + /// Creates a reply preview attachment. + StreamMessageComposerReplyAttachment({ + super.key, + required Widget title, + required Widget subtitle, + Widget? thumbnail, + VoidCallback? onRemovePressed, + StreamReplyDirection direction = .incoming, + StreamMessageComposerReplyAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + thumbnail: thumbnail, + onRemovePressed: onRemovePressed, + direction: direction, + style: style, + ); + + /// The properties that configure this reply preview. + final StreamMessageComposerReplyAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerReplyAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerReplyAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerReplyAttachment]. +/// +/// This class holds all the configuration options for a reply preview, allowing them to be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerReplyAttachment], the default implementation. +class StreamMessageComposerReplyAttachmentProps { + /// Creates properties for a reply preview attachment. + const StreamMessageComposerReplyAttachmentProps({ + required this.title, + required this.subtitle, + this.thumbnail, + this.onRemovePressed, + this.direction = .incoming, + this.style, + }); + + /// The primary label shown on the first line. + /// + /// Typically a [Text] showing the quoted user's name. Renders on a single line and + /// ellipsizes on overflow. + final Widget title; + + /// The secondary label shown below [title]. + /// + /// Typically a [Text] previewing the quoted message body. Renders on a single line and + /// ellipsizes on overflow. + final Widget subtitle; + + /// An optional thumbnail of the quoted attachment, rendered at the end of the preview row. + /// + /// Sized and shaped via the inherited theme (default 40×40 with rounded corners). The + /// caller-provided widget is responsible for filling the thumbnail bounds — typically an + /// [Image] with [BoxFit.cover]. When null, the title/subtitle column expands to the full + /// width of the row. + final Widget? thumbnail; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Selects direction-aware default colors for the background, indicator bar, and text. + /// + /// Has no effect on fields explicitly provided through [style] or the inherited theme. + /// Defaults to [StreamReplyDirection.incoming]. + final StreamReplyDirection direction; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerReplyAttachmentTheme], + /// then to direction-aware defaults selected by [direction]. + final StreamMessageComposerReplyAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerReplyAttachment]. +/// +/// Renders the reply preview with theming support from +/// [StreamMessageComposerReplyAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachment], the public API widget. +/// * [StreamMessageComposerReplyAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerReplyAttachment extends StatelessWidget { + /// Creates a default reply preview with the given [props]. + const DefaultStreamMessageComposerReplyAttachment({super.key, required this.props}); + + /// The properties that configure this reply preview. + final StreamMessageComposerReplyAttachmentProps props; + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerReplyAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerReplyAttachmentDefaults(context, props.direction); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveIndicatorColor = theme.indicatorColor ?? defaults.indicatorColor; + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.title, + ); + + final effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.subtitle, + ); + + Widget? effectiveThumbnail; + if (props.thumbnail case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + backgroundColor: effectiveBackgroundColor, + side: BorderSide.none, + ), + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: IntrinsicHeight( + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + VerticalDivider( + width: _kIndicatorWidth, + thickness: _kIndicatorWidth, + indent: _kIndicatorVerticalMargin, + endIndent: _kIndicatorVerticalMargin, + radius: BorderRadius.all(radius.max), + color: effectiveIndicatorColor, + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [effectiveTitle, effectiveSubtitle], + ), + ), + ?effectiveThumbnail, + ], + ), + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerReplyAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerReplyAttachmentThemeData]. Direction-aware defaults +// are selected by [_direction]. +class _StreamMessageComposerReplyAttachmentDefaults extends StreamMessageComposerReplyAttachmentThemeData { + _StreamMessageComposerReplyAttachmentDefaults(this._context, this._direction); + + final BuildContext _context; + final StreamReplyDirection _direction; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + late final Color _textColor = switch (_direction) { + StreamReplyDirection.incoming => _colorScheme.textPrimary, + StreamReplyDirection.outgoing => _colorScheme.brand.shade900, + }; + + @override + Color get backgroundColor => switch (_direction) { + StreamReplyDirection.incoming => _colorScheme.backgroundSurface, + StreamReplyDirection.outgoing => _colorScheme.brand.shade100, + }; + + @override + Color get indicatorColor => switch (_direction) { + StreamReplyDirection.incoming => _colorScheme.chrome.shade400, + StreamReplyDirection.outgoing => _colorScheme.brand.shade400, + }; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xs); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: .all(_context.streamRadius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart new file mode 100644 index 00000000..e8bfb846 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart @@ -0,0 +1,176 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(maxWidth: 260, maxHeight: 260, minHeight: 72); + +/// A composer attachment placeholder shown for attachments the client cannot render. +/// +/// [StreamMessageComposerUnsupportedAttachment] displays a leading "unsupported" glyph and a +/// single [label] line — e.g. for custom or third-party attachment types the SDK doesn't +/// have a renderer for. The label renders on a single line and ellipsizes on overflow. +/// +/// The row is at most 260 wide with a minimum height of 72. It shrinks to fit when a parent +/// bounds the width below 260, and the height grows to accommodate taller content (up to 260). +/// +/// {@tool snippet} +/// +/// An unsupported-attachment placeholder: +/// +/// ```dart +/// StreamMessageComposerUnsupportedAttachment( +/// label: Text('Unsupported attachment'), +/// onRemovePressed: () => removeAttachment(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerUnsupportedAttachment] uses +/// [StreamMessageComposerUnsupportedAttachmentThemeData] for default styling. Per-instance +/// [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachmentTheme], for customizing unsupported +/// placeholders globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [DefaultStreamMessageComposerUnsupportedAttachment], the default visual implementation. +class StreamMessageComposerUnsupportedAttachment extends StatelessWidget { + /// Creates an unsupported-attachment placeholder. + StreamMessageComposerUnsupportedAttachment({ + super.key, + required Widget label, + VoidCallback? onRemovePressed, + StreamMessageComposerUnsupportedAttachmentThemeData? style, + }) : props = .new( + label: label, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this placeholder. + final StreamMessageComposerUnsupportedAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerUnsupportedAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerUnsupportedAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerUnsupportedAttachment]. +/// +/// This class holds all the configuration options for an unsupported-attachment placeholder, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerUnsupportedAttachment], the default implementation. +class StreamMessageComposerUnsupportedAttachmentProps { + /// Creates properties for an unsupported-attachment placeholder. + const StreamMessageComposerUnsupportedAttachmentProps({ + required this.label, + this.onRemovePressed, + this.style, + }); + + /// The placeholder label. + /// + /// Typically a [Text] showing a localized "Unsupported attachment" string. Renders on a + /// single line and ellipsizes on overflow. + final Widget label; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited + /// [StreamMessageComposerUnsupportedAttachmentTheme], then to built-in defaults. + final StreamMessageComposerUnsupportedAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerUnsupportedAttachment]. +/// +/// Renders the placeholder with theming support from +/// [StreamMessageComposerUnsupportedAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachment], the public API widget. +/// * [StreamMessageComposerUnsupportedAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerUnsupportedAttachment extends StatelessWidget { + /// Creates a default unsupported-attachment placeholder with the given [props]. + const DefaultStreamMessageComposerUnsupportedAttachment({super.key, required this.props}); + + /// The properties that configure this placeholder. + final StreamMessageComposerUnsupportedAttachmentProps props; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + final theme = context.streamMessageComposerUnsupportedAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerUnsupportedAttachmentDefaults(context); + + final effectiveLabelStyle = theme.labelTextStyle ?? defaults.labelTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveSpacing = theme.spacing ?? defaults.spacing; + + final effectiveLabel = DefaultTextStyle.merge( + style: effectiveLabelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.label, + ); + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + children: [ + Icon(icons.unsupportedAttachment, size: 20, color: effectiveLabelStyle.color), + Expanded(child: effectiveLabel), + ], + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerUnsupportedAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerUnsupportedAttachmentThemeData]. +class _StreamMessageComposerUnsupportedAttachmentDefaults extends StreamMessageComposerUnsupportedAttachmentThemeData { + _StreamMessageComposerUnsupportedAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + @override + TextStyle get labelTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + EdgeInsetsGeometry get padding => + EdgeInsetsDirectional.only(start: _spacing.md, end: _spacing.sm, top: _spacing.md, bottom: _spacing.md); + + @override + double get spacing => _spacing.xs; +} 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 2371018e..85976bc5 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 @@ -365,6 +365,11 @@ class DefaultStreamReactions extends StatelessWidget { // when overlapping (later children have higher z-order). For // top-positioned reactions we flip verticalDirection so the column still // lays out bottom-to-top while keeping reactions last in the paint order. + // + // The bubble is wrapped in StreamIntrinsicBoundedCrossAxis so descendants + // like ListView(shrinkWrap: true) inside it receive a bounded width. + // Resolution still treats it as a regular child — column width remains + // max(bubble, strip). return StreamIntrinsicColumn( spacing: columnSpacing, crossAxisAlignment: effectiveCrossAxisAlignment, @@ -373,7 +378,10 @@ class DefaultStreamReactions extends StatelessWidget { .header => VerticalDirection.up, .footer => VerticalDirection.down, }, - children: [props.child!, alignedStrip], + children: [ + StreamIntrinsicBoundedCrossAxis(child: props.child!), + alignedStrip, + ], ); } 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 3252cb7d..d9e643e5 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 @@ -151,6 +151,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? loadingSpinner, StreamComponentBuilder? messageAnnotation, StreamComponentBuilder? messageBubble, + StreamComponentBuilder? messageComposerAttachment, + StreamComponentBuilder? messageComposerEditMessageAttachment, + StreamComponentBuilder? messageComposerFileAttachment, + StreamComponentBuilder? messageComposerLinkPreviewAttachment, + StreamComponentBuilder? messageComposerMediaAttachment, + StreamComponentBuilder? messageComposerReplyAttachment, + StreamComponentBuilder? messageComposerUnsupportedAttachment, StreamComponentBuilder? messageContent, StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, @@ -194,6 +201,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { loadingSpinner: loadingSpinner, messageAnnotation: messageAnnotation, messageBubble: messageBubble, + messageComposerAttachment: messageComposerAttachment, + messageComposerEditMessageAttachment: messageComposerEditMessageAttachment, + messageComposerFileAttachment: messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: messageComposerMediaAttachment, + messageComposerReplyAttachment: messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: messageComposerUnsupportedAttachment, messageContent: messageContent, messageMetadata: messageMetadata, messageReplies: messageReplies, @@ -238,6 +252,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.loadingSpinner, required this.messageAnnotation, required this.messageBubble, + required this.messageComposerAttachment, + required this.messageComposerEditMessageAttachment, + required this.messageComposerFileAttachment, + required this.messageComposerLinkPreviewAttachment, + required this.messageComposerMediaAttachment, + required this.messageComposerReplyAttachment, + required this.messageComposerUnsupportedAttachment, required this.messageContent, required this.messageMetadata, required this.messageReplies, @@ -379,6 +400,48 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamMessageBubble] uses [DefaultStreamMessageBubble]. final StreamComponentBuilder? messageBubble; + /// Custom builder for composer attachment container widgets. + /// + /// When null, [StreamMessageComposerAttachment] uses + /// [DefaultStreamMessageComposerAttachment]. + final StreamComponentBuilder? messageComposerAttachment; + + /// Custom builder for composer edit-message preview attachment widgets. + /// + /// When null, [StreamMessageComposerEditMessageAttachment] uses + /// [DefaultStreamMessageComposerEditMessageAttachment]. + final StreamComponentBuilder? messageComposerEditMessageAttachment; + + /// Custom builder for composer file attachment widgets. + /// + /// When null, [StreamMessageComposerFileAttachment] uses + /// [DefaultStreamMessageComposerFileAttachment]. + final StreamComponentBuilder? messageComposerFileAttachment; + + /// Custom builder for composer link preview attachment widgets. + /// + /// When null, [StreamMessageComposerLinkPreviewAttachment] uses + /// [DefaultStreamMessageComposerLinkPreviewAttachment]. + final StreamComponentBuilder? messageComposerLinkPreviewAttachment; + + /// Custom builder for composer media (image/video) attachment widgets. + /// + /// When null, [StreamMessageComposerMediaAttachment] uses + /// [DefaultStreamMessageComposerMediaAttachment]. + final StreamComponentBuilder? messageComposerMediaAttachment; + + /// Custom builder for composer reply preview attachment widgets. + /// + /// When null, [StreamMessageComposerReplyAttachment] uses + /// [DefaultStreamMessageComposerReplyAttachment]. + final StreamComponentBuilder? messageComposerReplyAttachment; + + /// Custom builder for composer unsupported-attachment placeholder widgets. + /// + /// When null, [StreamMessageComposerUnsupportedAttachment] uses + /// [DefaultStreamMessageComposerUnsupportedAttachment]. + final StreamComponentBuilder? messageComposerUnsupportedAttachment; + /// Custom builder for message content layout widgets. /// /// When null, [StreamMessageContent] uses [DefaultStreamMessageContent]. 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 12a940e8..2ad6a684 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 @@ -51,6 +51,27 @@ mixin _$StreamComponentBuilders { loadingSpinner: t < 0.5 ? a.loadingSpinner : b.loadingSpinner, messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, + messageComposerAttachment: t < 0.5 + ? a.messageComposerAttachment + : b.messageComposerAttachment, + messageComposerEditMessageAttachment: t < 0.5 + ? a.messageComposerEditMessageAttachment + : b.messageComposerEditMessageAttachment, + messageComposerFileAttachment: t < 0.5 + ? a.messageComposerFileAttachment + : b.messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: t < 0.5 + ? a.messageComposerLinkPreviewAttachment + : b.messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: t < 0.5 + ? a.messageComposerMediaAttachment + : b.messageComposerMediaAttachment, + messageComposerReplyAttachment: t < 0.5 + ? a.messageComposerReplyAttachment + : b.messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: t < 0.5 + ? a.messageComposerUnsupportedAttachment + : b.messageComposerUnsupportedAttachment, messageContent: t < 0.5 ? a.messageContent : b.messageContent, messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, @@ -100,6 +121,29 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamMessageAnnotationProps)? messageAnnotation, Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, + Widget Function(BuildContext, StreamMessageComposerAttachmentProps)? + messageComposerAttachment, + Widget Function( + BuildContext, + StreamMessageComposerEditMessageAttachmentProps, + )? + messageComposerEditMessageAttachment, + Widget Function(BuildContext, StreamMessageComposerFileAttachmentProps)? + messageComposerFileAttachment, + Widget Function( + BuildContext, + StreamMessageComposerLinkPreviewAttachmentProps, + )? + messageComposerLinkPreviewAttachment, + Widget Function(BuildContext, StreamMessageComposerMediaAttachmentProps)? + messageComposerMediaAttachment, + Widget Function(BuildContext, StreamMessageComposerReplyAttachmentProps)? + messageComposerReplyAttachment, + Widget Function( + BuildContext, + StreamMessageComposerUnsupportedAttachmentProps, + )? + messageComposerUnsupportedAttachment, Widget Function(BuildContext, StreamMessageContentProps)? messageContent, Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, @@ -146,6 +190,25 @@ mixin _$StreamComponentBuilders { loadingSpinner: loadingSpinner ?? _this.loadingSpinner, messageAnnotation: messageAnnotation ?? _this.messageAnnotation, messageBubble: messageBubble ?? _this.messageBubble, + messageComposerAttachment: + messageComposerAttachment ?? _this.messageComposerAttachment, + messageComposerEditMessageAttachment: + messageComposerEditMessageAttachment ?? + _this.messageComposerEditMessageAttachment, + messageComposerFileAttachment: + messageComposerFileAttachment ?? _this.messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: + messageComposerLinkPreviewAttachment ?? + _this.messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: + messageComposerMediaAttachment ?? + _this.messageComposerMediaAttachment, + messageComposerReplyAttachment: + messageComposerReplyAttachment ?? + _this.messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: + messageComposerUnsupportedAttachment ?? + _this.messageComposerUnsupportedAttachment, messageContent: messageContent ?? _this.messageContent, messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, @@ -200,6 +263,16 @@ mixin _$StreamComponentBuilders { loadingSpinner: other.loadingSpinner, messageAnnotation: other.messageAnnotation, messageBubble: other.messageBubble, + messageComposerAttachment: other.messageComposerAttachment, + messageComposerEditMessageAttachment: + other.messageComposerEditMessageAttachment, + messageComposerFileAttachment: other.messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: + other.messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: other.messageComposerMediaAttachment, + messageComposerReplyAttachment: other.messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: + other.messageComposerUnsupportedAttachment, messageContent: other.messageContent, messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, @@ -255,6 +328,19 @@ mixin _$StreamComponentBuilders { _other.loadingSpinner == _this.loadingSpinner && _other.messageAnnotation == _this.messageAnnotation && _other.messageBubble == _this.messageBubble && + _other.messageComposerAttachment == _this.messageComposerAttachment && + _other.messageComposerEditMessageAttachment == + _this.messageComposerEditMessageAttachment && + _other.messageComposerFileAttachment == + _this.messageComposerFileAttachment && + _other.messageComposerLinkPreviewAttachment == + _this.messageComposerLinkPreviewAttachment && + _other.messageComposerMediaAttachment == + _this.messageComposerMediaAttachment && + _other.messageComposerReplyAttachment == + _this.messageComposerReplyAttachment && + _other.messageComposerUnsupportedAttachment == + _this.messageComposerUnsupportedAttachment && _other.messageContent == _this.messageContent && _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && @@ -302,6 +388,13 @@ mixin _$StreamComponentBuilders { _this.loadingSpinner, _this.messageAnnotation, _this.messageBubble, + _this.messageComposerAttachment, + _this.messageComposerEditMessageAttachment, + _this.messageComposerFileAttachment, + _this.messageComposerLinkPreviewAttachment, + _this.messageComposerMediaAttachment, + _this.messageComposerReplyAttachment, + _this.messageComposerUnsupportedAttachment, _this.messageContent, _this.messageMetadata, _this.messageReplies, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index fdaeffb2..525442e6 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -17,12 +17,18 @@ export 'theme/components/stream_list_tile_theme.dart'; export 'theme/components/stream_message_annotation_theme.dart'; export 'theme/components/stream_message_attachment_theme.dart'; export 'theme/components/stream_message_bubble_theme.dart'; +export 'theme/components/stream_message_composer_attachment_theme.dart'; +export 'theme/components/stream_message_composer_edit_message_attachment_theme.dart'; +export 'theme/components/stream_message_composer_file_attachment_theme.dart'; +export 'theme/components/stream_message_composer_link_preview_attachment_theme.dart'; +export 'theme/components/stream_message_composer_media_attachment_theme.dart'; +export 'theme/components/stream_message_composer_reply_attachment_theme.dart'; +export 'theme/components/stream_message_composer_unsupported_attachment_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_playback_speed_toggle_theme.dart'; export 'theme/components/stream_progress_bar_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.dart new file mode 100644 index 00000000..a1d60017 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.dart @@ -0,0 +1,134 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_attachment_theme.g.theme.dart'; + +/// Applies a composer attachment theme to descendant +/// [StreamMessageComposerAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerAttachmentTheme] to override +/// styling for the attachment containers shown in the message composer. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the composer attachment background for a specific section: +/// +/// ```dart +/// StreamMessageComposerAttachmentTheme( +/// data: StreamMessageComposerAttachmentThemeData( +/// backgroundColor: Colors.blue.shade50, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerAttachmentThemeData], which describes the theme +/// data. +/// * [StreamMessageComposerAttachment], the widget affected by this theme. +class StreamMessageComposerAttachmentTheme extends InheritedTheme { + /// Creates a composer attachment theme that controls descendant attachment + /// containers. + const StreamMessageComposerAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer attachment theme data for descendant widgets. + final StreamMessageComposerAttachmentThemeData data; + + /// Returns the [StreamMessageComposerAttachmentThemeData] merged from local + /// and global themes. + /// + /// Local values from the nearest [StreamMessageComposerAttachmentTheme] + /// ancestor take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerAttachmentTheme.of]. All properties are null by +/// default, with fallback values applied by +/// [DefaultStreamMessageComposerAttachment]. +/// +/// {@tool snippet} +/// +/// Customize composer attachment containers globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerAttachmentTheme: StreamMessageComposerAttachmentThemeData( +/// backgroundColor: Colors.blue.shade50, +/// side: BorderSide(color: Colors.blue.shade200), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerAttachmentThemeData with _$StreamMessageComposerAttachmentThemeData { + /// Creates composer attachment theme data with optional overrides. + const StreamMessageComposerAttachmentThemeData({ + this.backgroundColor, + this.shape, + this.side, + this.padding, + }); + + /// Background fill color of the attachment container. + /// + /// If null, defaults to [StreamColorScheme.backgroundElevation1]. + final Color? backgroundColor; + + /// Outer shape of the attachment container. + /// + /// Composed with [side] to draw the container's border. If null, defaults + /// to a [RoundedRectangleBorder] with radius [StreamRadius.lg]. + final OutlinedBorder? shape; + + /// Border side drawn around the attachment container. + /// + /// Composed onto [shape] via [OutlinedBorder.copyWith]. If null, defaults + /// to a 1px [BorderSide] using [StreamColorScheme.borderDefault]. + final BorderSide? side; + + /// Outer padding around the attachment container, separating the container + /// from its siblings and reserving room for the optional remove control + /// overlay at the top-end corner. + /// + /// If null, defaults to [StreamSpacing.xxs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Linearly interpolate between two + /// [StreamMessageComposerAttachmentThemeData] objects. + static StreamMessageComposerAttachmentThemeData? lerp( + StreamMessageComposerAttachmentThemeData? a, + StreamMessageComposerAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart new file mode 100644 index 00000000..8156bfe1 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart @@ -0,0 +1,114 @@ +// 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_composer_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerAttachmentThemeData? lerp( + StreamMessageComposerAttachmentThemeData? a, + StreamMessageComposerAttachmentThemeData? 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 StreamMessageComposerAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + shape: OutlinedBorder.lerp(a.shape, b.shape, t), + side: a.side == null + ? b.side + : b.side == null + ? a.side + : BorderSide.lerp(a.side!, b.side!, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + ); + } + + StreamMessageComposerAttachmentThemeData copyWith({ + Color? backgroundColor, + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? padding, + }) { + final _this = (this as StreamMessageComposerAttachmentThemeData); + + return StreamMessageComposerAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + shape: shape ?? _this.shape, + side: side ?? _this.side, + padding: padding ?? _this.padding, + ); + } + + StreamMessageComposerAttachmentThemeData merge( + StreamMessageComposerAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + shape: other.shape, + side: _this.side != null && other.side != null + ? BorderSide.merge(_this.side!, other.side!) + : other.side, + padding: other.padding, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerAttachmentThemeData); + final _other = (other as StreamMessageComposerAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.shape == _this.shape && + _other.side == _this.side && + _other.padding == _this.padding; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.shape, + _this.side, + _this.padding, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart new file mode 100644 index 00000000..03a70b68 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart @@ -0,0 +1,155 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_edit_message_attachment_theme.g.theme.dart'; + +/// Applies a composer edit-message attachment theme to descendant +/// [StreamMessageComposerEditMessageAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerEditMessageAttachmentTheme] to override the styling +/// of the edit-message preview shown above the composer input. Access the merged theme using +/// [BuildContext.streamMessageComposerEditMessageAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the edit-message indicator color for a specific section: +/// +/// ```dart +/// StreamMessageComposerEditMessageAttachmentTheme( +/// data: StreamMessageComposerEditMessageAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachmentThemeData], which describes the theme data. +/// * [StreamMessageComposerEditMessageAttachment], the widget affected by this theme. +class StreamMessageComposerEditMessageAttachmentTheme extends InheritedTheme { + /// Creates a composer edit-message attachment theme that controls descendant edit previews. + const StreamMessageComposerEditMessageAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer edit-message attachment theme data for descendant widgets. + final StreamMessageComposerEditMessageAttachmentThemeData data; + + /// Returns the [StreamMessageComposerEditMessageAttachmentThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamMessageComposerEditMessageAttachmentTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerEditMessageAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerEditMessageAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerEditMessageAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerEditMessageAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerEditMessageAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerEditMessageAttachmentTheme.of]. All properties are null by default, +/// with fallback values applied by [DefaultStreamMessageComposerEditMessageAttachment] — a +/// brand-tinted background and indicator from the outgoing palette, with neutral text. +/// +/// {@tool snippet} +/// +/// Customize edit-message attachment styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerEditMessageAttachmentTheme: +/// StreamMessageComposerEditMessageAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachmentTheme], for overriding theme in a widget +/// subtree. +/// * [StreamMessageComposerEditMessageAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerEditMessageAttachmentThemeData with _$StreamMessageComposerEditMessageAttachmentThemeData { + /// Creates composer edit-message attachment theme data with optional overrides. + const StreamMessageComposerEditMessageAttachmentThemeData({ + this.backgroundColor, + this.indicatorColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// Background fill color of the edit-message preview card. + /// + /// If null, defaults to `colorScheme.brand.shade100`. + final Color? backgroundColor; + + /// Color of the leading indicator bar. + /// + /// If null, defaults to `colorScheme.brand.shade400`. + final Color? indicatorColor; + + /// Text style for the preview title (typically the localized "Edit message" label). + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? titleTextStyle; + + /// Text style for the preview subtitle (the message body being edited). + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? subtitleTextStyle; + + /// Padding around the preview's content row. + /// + /// If null, defaults to [StreamSpacing.xs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the trailing thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the trailing thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the trailing thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two [StreamMessageComposerEditMessageAttachmentThemeData] + /// objects. + static StreamMessageComposerEditMessageAttachmentThemeData? lerp( + StreamMessageComposerEditMessageAttachmentThemeData? a, + StreamMessageComposerEditMessageAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerEditMessageAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart new file mode 100644 index 00000000..e687a716 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart @@ -0,0 +1,151 @@ +// 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_composer_edit_message_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerEditMessageAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerEditMessageAttachmentThemeData? lerp( + StreamMessageComposerEditMessageAttachmentThemeData? a, + StreamMessageComposerEditMessageAttachmentThemeData? 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 StreamMessageComposerEditMessageAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamMessageComposerEditMessageAttachmentThemeData copyWith({ + Color? backgroundColor, + Color? indicatorColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + + return StreamMessageComposerEditMessageAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + indicatorColor: indicatorColor ?? _this.indicatorColor, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamMessageComposerEditMessageAttachmentThemeData merge( + StreamMessageComposerEditMessageAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + indicatorColor: other.indicatorColor, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + final _other = + (other as StreamMessageComposerEditMessageAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.indicatorColor == _this.indicatorColor && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.indicatorColor, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.dart new file mode 100644 index 00000000..1161d59b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.dart @@ -0,0 +1,134 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_file_attachment_theme.g.theme.dart'; + +/// Applies a composer file attachment theme to descendant +/// [StreamMessageComposerFileAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerFileAttachmentTheme] to override +/// the typography and spacing of file attachments rendered inside the +/// composer. Access the merged theme using +/// [BuildContext.streamMessageComposerFileAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the file attachment title style for a specific section: +/// +/// ```dart +/// StreamMessageComposerFileAttachmentTheme( +/// data: StreamMessageComposerFileAttachmentThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachmentThemeData], which describes the +/// theme data. +/// * [StreamMessageComposerFileAttachment], the widget affected by this theme. +class StreamMessageComposerFileAttachmentTheme extends InheritedTheme { + /// Creates a composer file attachment theme that controls descendant file + /// attachments. + const StreamMessageComposerFileAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer file attachment theme data for descendant widgets. + final StreamMessageComposerFileAttachmentThemeData data; + + /// Returns the [StreamMessageComposerFileAttachmentThemeData] merged from + /// local and global themes. + /// + /// Local values from the nearest [StreamMessageComposerFileAttachmentTheme] + /// ancestor take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerFileAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerFileAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerFileAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerFileAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerFileAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerFileAttachmentTheme.of]. All properties are null by +/// default, with fallback values applied by +/// [DefaultStreamMessageComposerFileAttachment]. +/// +/// {@tool snippet} +/// +/// Customize file attachment typography globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerFileAttachmentTheme: +/// StreamMessageComposerFileAttachmentThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerFileAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerFileAttachmentThemeData with _$StreamMessageComposerFileAttachmentThemeData { + /// Creates composer file attachment theme data with optional overrides. + const StreamMessageComposerFileAttachmentThemeData({ + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.spacing, + }); + + /// Text style for the file attachment title. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? titleTextStyle; + + /// Text style for the file attachment subtitle. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with + /// [StreamColorScheme.textSecondary]. + final TextStyle? subtitleTextStyle; + + /// Padding around the file attachment's content row. + /// + /// If null, defaults to a directional inset using [StreamSpacing.md] and + /// [StreamSpacing.sm] tokens. + final EdgeInsetsGeometry? padding; + + /// Horizontal space between the file type icon and the title/subtitle + /// column. + /// + /// If null, defaults to [StreamSpacing.sm]. + final double? spacing; + + /// Linearly interpolate between two + /// [StreamMessageComposerFileAttachmentThemeData] objects. + static StreamMessageComposerFileAttachmentThemeData? lerp( + StreamMessageComposerFileAttachmentThemeData? a, + StreamMessageComposerFileAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerFileAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart new file mode 100644 index 00000000..925e009d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart @@ -0,0 +1,116 @@ +// 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_composer_file_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerFileAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerFileAttachmentThemeData? lerp( + StreamMessageComposerFileAttachmentThemeData? a, + StreamMessageComposerFileAttachmentThemeData? 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 StreamMessageComposerFileAttachmentThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + ); + } + + StreamMessageComposerFileAttachmentThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + double? spacing, + }) { + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + + return StreamMessageComposerFileAttachmentThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + ); + } + + StreamMessageComposerFileAttachmentThemeData merge( + StreamMessageComposerFileAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + spacing: other.spacing, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + final _other = (other as StreamMessageComposerFileAttachmentThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.spacing == _this.spacing; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.spacing, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart new file mode 100644 index 00000000..cf442814 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart @@ -0,0 +1,158 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_link_preview_attachment_theme.g.theme.dart'; + +/// Applies a composer link preview attachment theme to descendant +/// [StreamMessageComposerLinkPreviewAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerLinkPreviewAttachmentTheme] to +/// override the styling of the link preview shown above the composer input. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerLinkPreviewAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the link preview background for a specific section: +/// +/// ```dart +/// StreamMessageComposerLinkPreviewAttachmentTheme( +/// data: StreamMessageComposerLinkPreviewAttachmentThemeData( +/// backgroundColor: Colors.amber.shade50, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachmentThemeData], which describes +/// the theme data. +/// * [StreamMessageComposerLinkPreviewAttachment], the widget affected by this +/// theme. +class StreamMessageComposerLinkPreviewAttachmentTheme extends InheritedTheme { + /// Creates a composer link preview attachment theme that controls + /// descendant link previews. + const StreamMessageComposerLinkPreviewAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer link preview attachment theme data for descendant widgets. + final StreamMessageComposerLinkPreviewAttachmentThemeData data; + + /// Returns the [StreamMessageComposerLinkPreviewAttachmentThemeData] merged + /// from local and global themes. + /// + /// Local values from the nearest + /// [StreamMessageComposerLinkPreviewAttachmentTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamMessageComposerLinkPreviewAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerLinkPreviewAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerLinkPreviewAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerLinkPreviewAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerLinkPreviewAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerLinkPreviewAttachmentTheme.of]. All properties are +/// null by default, with fallback values applied by +/// [DefaultStreamMessageComposerLinkPreviewAttachment]. +/// +/// {@tool snippet} +/// +/// Customize link preview styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerLinkPreviewAttachmentTheme: +/// StreamMessageComposerLinkPreviewAttachmentThemeData( +/// backgroundColor: Colors.amber.shade50, +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachmentTheme], for overriding theme +/// in a widget subtree. +/// * [StreamMessageComposerLinkPreviewAttachment], the widget that uses this theme +/// data. +@themeGen +@immutable +class StreamMessageComposerLinkPreviewAttachmentThemeData with _$StreamMessageComposerLinkPreviewAttachmentThemeData { + /// Creates composer link preview attachment theme data with optional + /// overrides. + const StreamMessageComposerLinkPreviewAttachmentThemeData({ + this.backgroundColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// Background fill color of the link preview card. + /// + /// If null, defaults to `colorScheme.brand.shade100`. + final Color? backgroundColor; + + /// Text style for the link preview title. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// `colorScheme.brand.shade900`. + final TextStyle? titleTextStyle; + + /// Text style for the link preview subtitle and URL. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with + /// `colorScheme.brand.shade900`. + final TextStyle? subtitleTextStyle; + + /// Padding around the link preview's content row. + /// + /// If null, defaults to a directional inset using [StreamSpacing.xs] and + /// [StreamSpacing.sm] tokens. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the leading thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the leading thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the leading thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two + /// [StreamMessageComposerLinkPreviewAttachmentThemeData] objects. + static StreamMessageComposerLinkPreviewAttachmentThemeData? lerp( + StreamMessageComposerLinkPreviewAttachmentThemeData? a, + StreamMessageComposerLinkPreviewAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerLinkPreviewAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart new file mode 100644 index 00000000..03f3bf25 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart @@ -0,0 +1,145 @@ +// 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_composer_link_preview_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerLinkPreviewAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerLinkPreviewAttachmentThemeData? lerp( + StreamMessageComposerLinkPreviewAttachmentThemeData? a, + StreamMessageComposerLinkPreviewAttachmentThemeData? 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 StreamMessageComposerLinkPreviewAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamMessageComposerLinkPreviewAttachmentThemeData copyWith({ + Color? backgroundColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + + return StreamMessageComposerLinkPreviewAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamMessageComposerLinkPreviewAttachmentThemeData merge( + StreamMessageComposerLinkPreviewAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + final _other = + (other as StreamMessageComposerLinkPreviewAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart new file mode 100644 index 00000000..add41471 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart @@ -0,0 +1,120 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_media_attachment_theme.g.theme.dart'; + +/// Applies a composer media attachment theme to descendant +/// [StreamMessageComposerMediaAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerMediaAttachmentTheme] to override +/// styling for image and video thumbnails rendered inside the composer. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerMediaAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the media attachment border for a specific section: +/// +/// ```dart +/// StreamMessageComposerMediaAttachmentTheme( +/// data: StreamMessageComposerMediaAttachmentThemeData( +/// borderColor: Colors.black12, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachmentThemeData], which describes the +/// theme data. +/// * [StreamMessageComposerMediaAttachment], the widget affected by this theme. +class StreamMessageComposerMediaAttachmentTheme extends InheritedTheme { + /// Creates a composer media attachment theme that controls descendant media + /// attachments. + const StreamMessageComposerMediaAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer media attachment theme data for descendant widgets. + final StreamMessageComposerMediaAttachmentThemeData data; + + /// Returns the [StreamMessageComposerMediaAttachmentThemeData] merged from + /// local and global themes. + /// + /// Local values from the nearest + /// [StreamMessageComposerMediaAttachmentTheme] ancestor take precedence + /// over global values from [StreamTheme.of]. + static StreamMessageComposerMediaAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerMediaAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerMediaAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerMediaAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerMediaAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerMediaAttachmentTheme.of]. All properties are null by +/// default, with fallback values applied by +/// [DefaultStreamMessageComposerMediaAttachment]. +/// +/// {@tool snippet} +/// +/// Customize media attachment styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerMediaAttachmentTheme: +/// StreamMessageComposerMediaAttachmentThemeData( +/// borderColor: Colors.black12, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerMediaAttachment], the widget that uses this theme +/// data. +@themeGen +@immutable +class StreamMessageComposerMediaAttachmentThemeData with _$StreamMessageComposerMediaAttachmentThemeData { + /// Creates composer media attachment theme data with optional overrides. + const StreamMessageComposerMediaAttachmentThemeData({ + this.borderColor, + this.size, + }); + + /// Border color drawn around the media attachment's container. + /// + /// If null, defaults to [StreamColorScheme.borderOpacitySubtle]. + final Color? borderColor; + + /// Dimensions of the media thumbnail. + /// + /// If null, defaults to `Size(72, 72)` (square). + final Size? size; + + /// Linearly interpolate between two + /// [StreamMessageComposerMediaAttachmentThemeData] objects. + static StreamMessageComposerMediaAttachmentThemeData? lerp( + StreamMessageComposerMediaAttachmentThemeData? a, + StreamMessageComposerMediaAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerMediaAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart new file mode 100644 index 00000000..623fca37 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart @@ -0,0 +1,88 @@ +// 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_composer_media_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerMediaAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerMediaAttachmentThemeData? lerp( + StreamMessageComposerMediaAttachmentThemeData? a, + StreamMessageComposerMediaAttachmentThemeData? 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 StreamMessageComposerMediaAttachmentThemeData( + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + size: Size.lerp(a.size, b.size, t), + ); + } + + StreamMessageComposerMediaAttachmentThemeData copyWith({ + Color? borderColor, + Size? size, + }) { + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + + return StreamMessageComposerMediaAttachmentThemeData( + borderColor: borderColor ?? _this.borderColor, + size: size ?? _this.size, + ); + } + + StreamMessageComposerMediaAttachmentThemeData merge( + StreamMessageComposerMediaAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(borderColor: other.borderColor, size: other.size); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + final _other = (other as StreamMessageComposerMediaAttachmentThemeData); + + return _other.borderColor == _this.borderColor && _other.size == _this.size; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + + return Object.hash(runtimeType, _this.borderColor, _this.size); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.dart new file mode 100644 index 00000000..4ed02c0e --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.dart @@ -0,0 +1,164 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_reply_attachment_theme.g.theme.dart'; + +/// Applies a composer reply attachment theme to descendant +/// [StreamMessageComposerReplyAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerReplyAttachmentTheme] to +/// override the styling of the reply preview shown above the composer input. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerReplyAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the reply attachment indicator color for a specific section: +/// +/// ```dart +/// StreamMessageComposerReplyAttachmentTheme( +/// data: StreamMessageComposerReplyAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachmentThemeData], which describes the +/// theme data. +/// * [StreamMessageComposerReplyAttachment], the widget affected by this theme. +class StreamMessageComposerReplyAttachmentTheme extends InheritedTheme { + /// Creates a composer reply attachment theme that controls descendant reply + /// previews. + const StreamMessageComposerReplyAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer reply attachment theme data for descendant widgets. + final StreamMessageComposerReplyAttachmentThemeData data; + + /// Returns the [StreamMessageComposerReplyAttachmentThemeData] merged from + /// local and global themes. + /// + /// Local values from the nearest + /// [StreamMessageComposerReplyAttachmentTheme] ancestor take precedence + /// over global values from [StreamTheme.of]. + static StreamMessageComposerReplyAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerReplyAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerReplyAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerReplyAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerReplyAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerReplyAttachmentTheme.of]. All properties are null by +/// default, with direction-aware fallback values applied by +/// [DefaultStreamMessageComposerReplyAttachment] based on the widget's +/// [StreamReplyDirection] (incoming or outgoing). +/// +/// {@tool snippet} +/// +/// Customize reply attachment styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerReplyAttachmentTheme: +/// StreamMessageComposerReplyAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerReplyAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerReplyAttachmentThemeData with _$StreamMessageComposerReplyAttachmentThemeData { + /// Creates composer reply attachment theme data with optional overrides. + const StreamMessageComposerReplyAttachmentThemeData({ + this.backgroundColor, + this.indicatorColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// Background fill color of the reply preview card. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.backgroundSurface` for incoming replies, + /// `colorScheme.brand.shade100` for outgoing. + final Color? backgroundColor; + + /// Color of the leading indicator bar. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.chrome.shade400` for incoming replies, + /// `colorScheme.brand.shade400` for outgoing. + final Color? indicatorColor; + + /// Text style for the reply title (typically the quoted user's name). + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with the + /// direction-aware text color. + final TextStyle? titleTextStyle; + + /// Text style for the reply subtitle (the quoted message preview). + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with the + /// direction-aware text color. + final TextStyle? subtitleTextStyle; + + /// Padding around the reply preview's content row. + /// + /// If null, defaults to [StreamSpacing.xs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the trailing thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the trailing thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the trailing thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two + /// [StreamMessageComposerReplyAttachmentThemeData] objects. + static StreamMessageComposerReplyAttachmentThemeData? lerp( + StreamMessageComposerReplyAttachmentThemeData? a, + StreamMessageComposerReplyAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerReplyAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart new file mode 100644 index 00000000..0b048cd5 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_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_composer_reply_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerReplyAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerReplyAttachmentThemeData? lerp( + StreamMessageComposerReplyAttachmentThemeData? a, + StreamMessageComposerReplyAttachmentThemeData? 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 StreamMessageComposerReplyAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamMessageComposerReplyAttachmentThemeData copyWith({ + Color? backgroundColor, + Color? indicatorColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + + return StreamMessageComposerReplyAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + indicatorColor: indicatorColor ?? _this.indicatorColor, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamMessageComposerReplyAttachmentThemeData merge( + StreamMessageComposerReplyAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + indicatorColor: other.indicatorColor, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + final _other = (other as StreamMessageComposerReplyAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.indicatorColor == _this.indicatorColor && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.indicatorColor, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart new file mode 100644 index 00000000..c9999ccb --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart @@ -0,0 +1,122 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_unsupported_attachment_theme.g.theme.dart'; + +/// Applies a composer unsupported-attachment theme to descendant +/// [StreamMessageComposerUnsupportedAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerUnsupportedAttachmentTheme] to override the +/// styling of the placeholder shown for attachments the client cannot render. Access the merged +/// theme using [BuildContext.streamMessageComposerUnsupportedAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the unsupported-attachment label style for a specific section: +/// +/// ```dart +/// StreamMessageComposerUnsupportedAttachmentTheme( +/// data: StreamMessageComposerUnsupportedAttachmentThemeData( +/// labelTextStyle: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachmentThemeData], which describes the theme data. +/// * [StreamMessageComposerUnsupportedAttachment], the widget affected by this theme. +class StreamMessageComposerUnsupportedAttachmentTheme extends InheritedTheme { + /// Creates a composer unsupported-attachment theme that controls descendant placeholders. + const StreamMessageComposerUnsupportedAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer unsupported-attachment theme data for descendant widgets. + final StreamMessageComposerUnsupportedAttachmentThemeData data; + + /// Returns the [StreamMessageComposerUnsupportedAttachmentThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamMessageComposerUnsupportedAttachmentTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerUnsupportedAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerUnsupportedAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerUnsupportedAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerUnsupportedAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerUnsupportedAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerUnsupportedAttachmentTheme.of]. All properties are null by default, +/// with fallback values applied by [DefaultStreamMessageComposerUnsupportedAttachment]. +/// +/// {@tool snippet} +/// +/// Customize the unsupported-attachment label globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerUnsupportedAttachmentTheme: +/// StreamMessageComposerUnsupportedAttachmentThemeData( +/// labelTextStyle: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachmentTheme], for overriding theme in a widget +/// subtree. +/// * [StreamMessageComposerUnsupportedAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerUnsupportedAttachmentThemeData with _$StreamMessageComposerUnsupportedAttachmentThemeData { + /// Creates composer unsupported-attachment theme data with optional overrides. + const StreamMessageComposerUnsupportedAttachmentThemeData({ + this.labelTextStyle, + this.padding, + this.spacing, + }); + + /// Text style for the placeholder label. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? labelTextStyle; + + /// Padding around the placeholder's content row. + /// + /// If null, defaults to a directional inset using [StreamSpacing.md] and [StreamSpacing.sm] + /// tokens. + final EdgeInsetsGeometry? padding; + + /// Horizontal space between the leading icon and the label. + /// + /// If null, defaults to [StreamSpacing.xs]. + final double? spacing; + + /// Linearly interpolate between two + /// [StreamMessageComposerUnsupportedAttachmentThemeData] objects. + static StreamMessageComposerUnsupportedAttachmentThemeData? lerp( + StreamMessageComposerUnsupportedAttachmentThemeData? a, + StreamMessageComposerUnsupportedAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerUnsupportedAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.g.theme.dart new file mode 100644 index 00000000..c262cac8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.g.theme.dart @@ -0,0 +1,105 @@ +// 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_composer_unsupported_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerUnsupportedAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerUnsupportedAttachmentThemeData? lerp( + StreamMessageComposerUnsupportedAttachmentThemeData? a, + StreamMessageComposerUnsupportedAttachmentThemeData? 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 StreamMessageComposerUnsupportedAttachmentThemeData( + labelTextStyle: TextStyle.lerp(a.labelTextStyle, b.labelTextStyle, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + ); + } + + StreamMessageComposerUnsupportedAttachmentThemeData copyWith({ + TextStyle? labelTextStyle, + EdgeInsetsGeometry? padding, + double? spacing, + }) { + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + + return StreamMessageComposerUnsupportedAttachmentThemeData( + labelTextStyle: labelTextStyle ?? _this.labelTextStyle, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + ); + } + + StreamMessageComposerUnsupportedAttachmentThemeData merge( + StreamMessageComposerUnsupportedAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + labelTextStyle: + _this.labelTextStyle?.merge(other.labelTextStyle) ?? + other.labelTextStyle, + padding: other.padding, + spacing: other.spacing, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + final _other = + (other as StreamMessageComposerUnsupportedAttachmentThemeData); + + return _other.labelTextStyle == _this.labelTextStyle && + _other.padding == _this.padding && + _other.spacing == _this.spacing; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.labelTextStyle, + _this.padding, + _this.spacing, + ); + } +} 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 deleted file mode 100644 index c51a7656..00000000 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -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); - } - - @override - Widget wrap(BuildContext context, Widget child) { - return StreamMessageTheme(data: data, child: child); - } - - @override - 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) { - final defaults = _MessageThemeDefaults(context: context); - return StreamMessageThemeData(incoming: defaults.incoming, outgoing: defaults.outgoing).merge(this); - } -} - -/// 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, - this.backgroundTypingIndicatorColor, - this.textColor, - this.textUsernameColor, - this.textTimestampColor, - this.textMentionColor, - this.textLinkColor, - this.textReactionColor, - this.textSystemColor, - this.textReadColor, - this.borderColor, - this.borderOnChatColor, - this.threadConnectorColor, - this.progressTrackColor, - this.progressFillColor, - this.replyIndicatorColor, - this.waveFormBarColor, - this.waveFormBarPlayingColor, - }); - - final Color? backgroundColor; - final Color? backgroundAttachmentColor; - final Color? backgroundTypingIndicatorColor; - - final Color? textColor; - final Color? textUsernameColor; - final Color? textTimestampColor; - final Color? textMentionColor; - final Color? textLinkColor; - final Color? textReactionColor; - final Color? textSystemColor; - final Color? textReadColor; - - final Color? borderColor; - final Color? borderOnChatColor; - - final Color? threadConnectorColor; - - final Color? progressTrackColor; - final Color? progressFillColor; - - final Color? replyIndicatorColor; - - final Color? waveFormBarColor; - final Color? waveFormBarPlayingColor; -} - -class _MessageThemeDefaults { - _MessageThemeDefaults({required this.context}) : _colorScheme = context.streamColorScheme; - - final BuildContext context; - final StreamColorScheme _colorScheme; - - StreamMessageStyle get incoming => StreamMessageStyle( - backgroundColor: _colorScheme.backgroundSurface, - backgroundAttachmentColor: _colorScheme.backgroundSurfaceStrong, - backgroundTypingIndicatorColor: _colorScheme.accentNeutral, - textColor: _colorScheme.textPrimary, - textUsernameColor: _colorScheme.textSecondary, - textTimestampColor: _colorScheme.textTertiary, - textMentionColor: _colorScheme.textLink, - textLinkColor: _colorScheme.textLink, - textReactionColor: _colorScheme.textSecondary, - textSystemColor: _colorScheme.textSecondary, - textReadColor: _colorScheme.accentPrimary, - borderColor: _colorScheme.borderSubtle, - borderOnChatColor: _colorScheme.borderStrong, - threadConnectorColor: _colorScheme.borderDefault, - replyIndicatorColor: _colorScheme.chrome.shade400, - progressTrackColor: _colorScheme.backgroundSurfaceStrong, - progressFillColor: _colorScheme.accentNeutral, - waveFormBarColor: _colorScheme.borderOpacityStrong, - waveFormBarPlayingColor: _colorScheme.accentPrimary, - ); - - StreamMessageStyle get outgoing => StreamMessageStyle( - backgroundColor: _colorScheme.brand.shade100, - backgroundAttachmentColor: _colorScheme.brand.shade150, - backgroundTypingIndicatorColor: _colorScheme.accentNeutral, - textColor: _colorScheme.brand.shade900, - textUsernameColor: _colorScheme.textSecondary, - textTimestampColor: _colorScheme.textTertiary, - textMentionColor: _colorScheme.textLink, - textLinkColor: _colorScheme.textLink, - textReactionColor: _colorScheme.textSecondary, - textSystemColor: _colorScheme.textSecondary, - textReadColor: _colorScheme.accentPrimary, - borderColor: _colorScheme.brand.shade100, - borderOnChatColor: _colorScheme.brand.shade300, - threadConnectorColor: _colorScheme.brand.shade150, - replyIndicatorColor: _colorScheme.brand.shade400, - progressTrackColor: _colorScheme.brand.shade200, - progressFillColor: _colorScheme.accentPrimary, - waveFormBarColor: _colorScheme.borderOpacityStrong, - waveFormBarPlayingColor: _colorScheme.accentPrimary, - ); -} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart deleted file mode 100644 index 94bb2fad..00000000 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart +++ /dev/null @@ -1,325 +0,0 @@ -// 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_theme.dart'; - -// ************************************************************************** -// ThemeGenGenerator -// ************************************************************************** - -mixin _$StreamMessageThemeData { - bool get canMerge => true; - - static StreamMessageThemeData? lerp( - StreamMessageThemeData? a, - StreamMessageThemeData? 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 StreamMessageThemeData( - incoming: t < 0.5 ? a.incoming : b.incoming, - outgoing: t < 0.5 ? a.outgoing : b.outgoing, - ); - } - - StreamMessageThemeData copyWith({ - StreamMessageStyle? incoming, - StreamMessageStyle? outgoing, - }) { - final _this = (this as StreamMessageThemeData); - - return StreamMessageThemeData( - incoming: incoming ?? _this.incoming, - outgoing: outgoing ?? _this.outgoing, - ); - } - - StreamMessageThemeData merge(StreamMessageThemeData? other) { - final _this = (this as StreamMessageThemeData); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith( - incoming: _this.incoming?.merge(other.incoming) ?? other.incoming, - outgoing: _this.outgoing?.merge(other.outgoing) ?? other.outgoing, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageThemeData); - final _other = (other as StreamMessageThemeData); - - return _other.incoming == _this.incoming && - _other.outgoing == _this.outgoing; - } - - @override - int get hashCode { - final _this = (this as StreamMessageThemeData); - - return Object.hash(runtimeType, _this.incoming, _this.outgoing); - } -} - -mixin _$StreamMessageStyle { - bool get canMerge => true; - - static StreamMessageStyle? lerp( - StreamMessageStyle? a, - StreamMessageStyle? 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 StreamMessageStyle( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - backgroundAttachmentColor: Color.lerp( - a.backgroundAttachmentColor, - b.backgroundAttachmentColor, - t, - ), - backgroundTypingIndicatorColor: Color.lerp( - a.backgroundTypingIndicatorColor, - b.backgroundTypingIndicatorColor, - t, - ), - textColor: Color.lerp(a.textColor, b.textColor, t), - textUsernameColor: Color.lerp( - a.textUsernameColor, - b.textUsernameColor, - t, - ), - textTimestampColor: Color.lerp( - a.textTimestampColor, - b.textTimestampColor, - t, - ), - textMentionColor: Color.lerp(a.textMentionColor, b.textMentionColor, t), - textLinkColor: Color.lerp(a.textLinkColor, b.textLinkColor, t), - textReactionColor: Color.lerp( - a.textReactionColor, - b.textReactionColor, - t, - ), - textSystemColor: Color.lerp(a.textSystemColor, b.textSystemColor, t), - textReadColor: Color.lerp(a.textReadColor, b.textReadColor, t), - borderColor: Color.lerp(a.borderColor, b.borderColor, t), - borderOnChatColor: Color.lerp( - a.borderOnChatColor, - b.borderOnChatColor, - t, - ), - threadConnectorColor: Color.lerp( - a.threadConnectorColor, - b.threadConnectorColor, - t, - ), - progressTrackColor: Color.lerp( - a.progressTrackColor, - b.progressTrackColor, - t, - ), - progressFillColor: Color.lerp( - a.progressFillColor, - b.progressFillColor, - t, - ), - replyIndicatorColor: Color.lerp( - a.replyIndicatorColor, - b.replyIndicatorColor, - t, - ), - waveFormBarColor: Color.lerp(a.waveFormBarColor, b.waveFormBarColor, t), - waveFormBarPlayingColor: Color.lerp( - a.waveFormBarPlayingColor, - b.waveFormBarPlayingColor, - t, - ), - ); - } - - StreamMessageStyle copyWith({ - Color? backgroundColor, - Color? backgroundAttachmentColor, - Color? backgroundTypingIndicatorColor, - Color? textColor, - Color? textUsernameColor, - Color? textTimestampColor, - Color? textMentionColor, - Color? textLinkColor, - Color? textReactionColor, - Color? textSystemColor, - Color? textReadColor, - Color? borderColor, - Color? borderOnChatColor, - Color? threadConnectorColor, - Color? progressTrackColor, - Color? progressFillColor, - Color? replyIndicatorColor, - Color? waveFormBarColor, - Color? waveFormBarPlayingColor, - }) { - final _this = (this as StreamMessageStyle); - - return StreamMessageStyle( - backgroundColor: backgroundColor ?? _this.backgroundColor, - backgroundAttachmentColor: - backgroundAttachmentColor ?? _this.backgroundAttachmentColor, - backgroundTypingIndicatorColor: - backgroundTypingIndicatorColor ?? - _this.backgroundTypingIndicatorColor, - textColor: textColor ?? _this.textColor, - textUsernameColor: textUsernameColor ?? _this.textUsernameColor, - textTimestampColor: textTimestampColor ?? _this.textTimestampColor, - textMentionColor: textMentionColor ?? _this.textMentionColor, - textLinkColor: textLinkColor ?? _this.textLinkColor, - textReactionColor: textReactionColor ?? _this.textReactionColor, - textSystemColor: textSystemColor ?? _this.textSystemColor, - textReadColor: textReadColor ?? _this.textReadColor, - borderColor: borderColor ?? _this.borderColor, - borderOnChatColor: borderOnChatColor ?? _this.borderOnChatColor, - threadConnectorColor: threadConnectorColor ?? _this.threadConnectorColor, - progressTrackColor: progressTrackColor ?? _this.progressTrackColor, - progressFillColor: progressFillColor ?? _this.progressFillColor, - replyIndicatorColor: replyIndicatorColor ?? _this.replyIndicatorColor, - waveFormBarColor: waveFormBarColor ?? _this.waveFormBarColor, - waveFormBarPlayingColor: - waveFormBarPlayingColor ?? _this.waveFormBarPlayingColor, - ); - } - - StreamMessageStyle merge(StreamMessageStyle? other) { - final _this = (this as StreamMessageStyle); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith( - backgroundColor: other.backgroundColor, - backgroundAttachmentColor: other.backgroundAttachmentColor, - backgroundTypingIndicatorColor: other.backgroundTypingIndicatorColor, - textColor: other.textColor, - textUsernameColor: other.textUsernameColor, - textTimestampColor: other.textTimestampColor, - textMentionColor: other.textMentionColor, - textLinkColor: other.textLinkColor, - textReactionColor: other.textReactionColor, - textSystemColor: other.textSystemColor, - textReadColor: other.textReadColor, - borderColor: other.borderColor, - borderOnChatColor: other.borderOnChatColor, - threadConnectorColor: other.threadConnectorColor, - progressTrackColor: other.progressTrackColor, - progressFillColor: other.progressFillColor, - replyIndicatorColor: other.replyIndicatorColor, - waveFormBarColor: other.waveFormBarColor, - waveFormBarPlayingColor: other.waveFormBarPlayingColor, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageStyle); - final _other = (other as StreamMessageStyle); - - return _other.backgroundColor == _this.backgroundColor && - _other.backgroundAttachmentColor == _this.backgroundAttachmentColor && - _other.backgroundTypingIndicatorColor == - _this.backgroundTypingIndicatorColor && - _other.textColor == _this.textColor && - _other.textUsernameColor == _this.textUsernameColor && - _other.textTimestampColor == _this.textTimestampColor && - _other.textMentionColor == _this.textMentionColor && - _other.textLinkColor == _this.textLinkColor && - _other.textReactionColor == _this.textReactionColor && - _other.textSystemColor == _this.textSystemColor && - _other.textReadColor == _this.textReadColor && - _other.borderColor == _this.borderColor && - _other.borderOnChatColor == _this.borderOnChatColor && - _other.threadConnectorColor == _this.threadConnectorColor && - _other.progressTrackColor == _this.progressTrackColor && - _other.progressFillColor == _this.progressFillColor && - _other.replyIndicatorColor == _this.replyIndicatorColor && - _other.waveFormBarColor == _this.waveFormBarColor && - _other.waveFormBarPlayingColor == _this.waveFormBarPlayingColor; - } - - @override - int get hashCode { - final _this = (this as StreamMessageStyle); - - return Object.hash( - runtimeType, - _this.backgroundColor, - _this.backgroundAttachmentColor, - _this.backgroundTypingIndicatorColor, - _this.textColor, - _this.textUsernameColor, - _this.textTimestampColor, - _this.textMentionColor, - _this.textLinkColor, - _this.textReactionColor, - _this.textSystemColor, - _this.textReadColor, - _this.borderColor, - _this.borderOnChatColor, - _this.threadConnectorColor, - _this.progressTrackColor, - _this.progressFillColor, - _this.replyIndicatorColor, - _this.waveFormBarColor, - _this.waveFormBarPlayingColor, - ); - } -} 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 ffd5eeb9..db934f47 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -18,8 +18,14 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_composer_attachment_theme.dart'; +import 'components/stream_message_composer_edit_message_attachment_theme.dart'; +import 'components/stream_message_composer_file_attachment_theme.dart'; +import 'components/stream_message_composer_link_preview_attachment_theme.dart'; +import 'components/stream_message_composer_media_attachment_theme.dart'; +import 'components/stream_message_composer_reply_attachment_theme.dart'; +import 'components/stream_message_composer_unsupported_attachment_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_playback_speed_toggle_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -119,8 +125,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiChipThemeData? emojiChipTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, + StreamMessageComposerAttachmentThemeData? messageComposerAttachmentTheme, + StreamMessageComposerEditMessageAttachmentThemeData? messageComposerEditMessageAttachmentTheme, + StreamMessageComposerFileAttachmentThemeData? messageComposerFileAttachmentTheme, + StreamMessageComposerLinkPreviewAttachmentThemeData? messageComposerLinkPreviewAttachmentTheme, + StreamMessageComposerMediaAttachmentThemeData? messageComposerMediaAttachmentTheme, + StreamMessageComposerReplyAttachmentThemeData? messageComposerReplyAttachmentTheme, + StreamMessageComposerUnsupportedAttachmentThemeData? messageComposerUnsupportedAttachmentTheme, StreamMessageItemThemeData? messageItemTheme, - StreamMessageThemeData? messageTheme, StreamTextInputThemeData? textInputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamPlaybackSpeedToggleThemeData? playbackSpeedToggleTheme, @@ -162,8 +174,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme ??= const StreamEmojiChipThemeData(); jumpToUnreadButtonTheme ??= const StreamJumpToUnreadButtonThemeData(); listTileTheme ??= const StreamListTileThemeData(); + messageComposerAttachmentTheme ??= const StreamMessageComposerAttachmentThemeData(); + messageComposerEditMessageAttachmentTheme ??= const StreamMessageComposerEditMessageAttachmentThemeData(); + messageComposerFileAttachmentTheme ??= const StreamMessageComposerFileAttachmentThemeData(); + messageComposerLinkPreviewAttachmentTheme ??= const StreamMessageComposerLinkPreviewAttachmentThemeData(); + messageComposerMediaAttachmentTheme ??= const StreamMessageComposerMediaAttachmentThemeData(); + messageComposerReplyAttachmentTheme ??= const StreamMessageComposerReplyAttachmentThemeData(); + messageComposerUnsupportedAttachmentTheme ??= const StreamMessageComposerUnsupportedAttachmentThemeData(); messageItemTheme ??= const StreamMessageItemThemeData(); - messageTheme ??= const StreamMessageThemeData(); textInputTheme ??= const StreamTextInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); playbackSpeedToggleTheme ??= const StreamPlaybackSpeedToggleThemeData(); @@ -199,8 +217,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme: emojiChipTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, + messageComposerAttachmentTheme: messageComposerAttachmentTheme, + messageComposerEditMessageAttachmentTheme: messageComposerEditMessageAttachmentTheme, + messageComposerFileAttachmentTheme: messageComposerFileAttachmentTheme, + messageComposerLinkPreviewAttachmentTheme: messageComposerLinkPreviewAttachmentTheme, + messageComposerMediaAttachmentTheme: messageComposerMediaAttachmentTheme, + messageComposerReplyAttachmentTheme: messageComposerReplyAttachmentTheme, + messageComposerUnsupportedAttachmentTheme: messageComposerUnsupportedAttachmentTheme, messageItemTheme: messageItemTheme, - messageTheme: messageTheme, textInputTheme: textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme, playbackSpeedToggleTheme: playbackSpeedToggleTheme, @@ -250,8 +274,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiChipTheme, required this.jumpToUnreadButtonTheme, required this.listTileTheme, + required this.messageComposerAttachmentTheme, + required this.messageComposerEditMessageAttachmentTheme, + required this.messageComposerFileAttachmentTheme, + required this.messageComposerLinkPreviewAttachmentTheme, + required this.messageComposerMediaAttachmentTheme, + required this.messageComposerReplyAttachmentTheme, + required this.messageComposerUnsupportedAttachmentTheme, required this.messageItemTheme, - required this.messageTheme, required this.textInputTheme, required this.onlineIndicatorTheme, required this.playbackSpeedToggleTheme, @@ -365,14 +395,32 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; + /// The composer attachment container theme for this theme. + final StreamMessageComposerAttachmentThemeData messageComposerAttachmentTheme; + + /// The composer edit-message attachment theme for this theme. + final StreamMessageComposerEditMessageAttachmentThemeData messageComposerEditMessageAttachmentTheme; + + /// The composer file attachment theme for this theme. + final StreamMessageComposerFileAttachmentThemeData messageComposerFileAttachmentTheme; + + /// The composer link preview attachment theme for this theme. + final StreamMessageComposerLinkPreviewAttachmentThemeData messageComposerLinkPreviewAttachmentTheme; + + /// The composer media attachment theme for this theme. + final StreamMessageComposerMediaAttachmentThemeData messageComposerMediaAttachmentTheme; + + /// The composer reply attachment theme for this theme. + final StreamMessageComposerReplyAttachmentThemeData messageComposerReplyAttachmentTheme; + + /// The composer unsupported attachment theme for this theme. + final StreamMessageComposerUnsupportedAttachmentThemeData messageComposerUnsupportedAttachmentTheme; + /// 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; - /// The text input theme for this theme. final StreamTextInputThemeData textInputTheme; @@ -448,8 +496,14 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme: emojiChipTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, + messageComposerAttachmentTheme: messageComposerAttachmentTheme, + messageComposerEditMessageAttachmentTheme: messageComposerEditMessageAttachmentTheme, + messageComposerFileAttachmentTheme: messageComposerFileAttachmentTheme, + messageComposerLinkPreviewAttachmentTheme: messageComposerLinkPreviewAttachmentTheme, + messageComposerMediaAttachmentTheme: messageComposerMediaAttachmentTheme, + messageComposerReplyAttachmentTheme: messageComposerReplyAttachmentTheme, + messageComposerUnsupportedAttachmentTheme: messageComposerUnsupportedAttachmentTheme, messageItemTheme: messageItemTheme, - messageTheme: messageTheme, textInputTheme: textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme, playbackSpeedToggleTheme: playbackSpeedToggleTheme, 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 fdc48da8..a7b8926e 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 @@ -34,8 +34,20 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiChipThemeData? emojiChipTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, + StreamMessageComposerAttachmentThemeData? messageComposerAttachmentTheme, + StreamMessageComposerEditMessageAttachmentThemeData? + messageComposerEditMessageAttachmentTheme, + StreamMessageComposerFileAttachmentThemeData? + messageComposerFileAttachmentTheme, + StreamMessageComposerLinkPreviewAttachmentThemeData? + messageComposerLinkPreviewAttachmentTheme, + StreamMessageComposerMediaAttachmentThemeData? + messageComposerMediaAttachmentTheme, + StreamMessageComposerReplyAttachmentThemeData? + messageComposerReplyAttachmentTheme, + StreamMessageComposerUnsupportedAttachmentThemeData? + messageComposerUnsupportedAttachmentTheme, StreamMessageItemThemeData? messageItemTheme, - StreamMessageThemeData? messageTheme, StreamTextInputThemeData? textInputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamPlaybackSpeedToggleThemeData? playbackSpeedToggleTheme, @@ -76,8 +88,28 @@ mixin _$StreamTheme on ThemeExtension { jumpToUnreadButtonTheme: jumpToUnreadButtonTheme ?? _this.jumpToUnreadButtonTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, + messageComposerAttachmentTheme: + messageComposerAttachmentTheme ?? + _this.messageComposerAttachmentTheme, + messageComposerEditMessageAttachmentTheme: + messageComposerEditMessageAttachmentTheme ?? + _this.messageComposerEditMessageAttachmentTheme, + messageComposerFileAttachmentTheme: + messageComposerFileAttachmentTheme ?? + _this.messageComposerFileAttachmentTheme, + messageComposerLinkPreviewAttachmentTheme: + messageComposerLinkPreviewAttachmentTheme ?? + _this.messageComposerLinkPreviewAttachmentTheme, + messageComposerMediaAttachmentTheme: + messageComposerMediaAttachmentTheme ?? + _this.messageComposerMediaAttachmentTheme, + messageComposerReplyAttachmentTheme: + messageComposerReplyAttachmentTheme ?? + _this.messageComposerReplyAttachmentTheme, + messageComposerUnsupportedAttachmentTheme: + messageComposerUnsupportedAttachmentTheme ?? + _this.messageComposerUnsupportedAttachmentTheme, messageItemTheme: messageItemTheme ?? _this.messageItemTheme, - messageTheme: messageTheme ?? _this.messageTheme, textInputTheme: textInputTheme ?? _this.textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, playbackSpeedToggleTheme: @@ -184,12 +216,53 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, + messageComposerAttachmentTheme: + StreamMessageComposerAttachmentThemeData.lerp( + _this.messageComposerAttachmentTheme, + other.messageComposerAttachmentTheme, + t, + )!, + messageComposerEditMessageAttachmentTheme: + StreamMessageComposerEditMessageAttachmentThemeData.lerp( + _this.messageComposerEditMessageAttachmentTheme, + other.messageComposerEditMessageAttachmentTheme, + t, + )!, + messageComposerFileAttachmentTheme: + StreamMessageComposerFileAttachmentThemeData.lerp( + _this.messageComposerFileAttachmentTheme, + other.messageComposerFileAttachmentTheme, + t, + )!, + messageComposerLinkPreviewAttachmentTheme: + StreamMessageComposerLinkPreviewAttachmentThemeData.lerp( + _this.messageComposerLinkPreviewAttachmentTheme, + other.messageComposerLinkPreviewAttachmentTheme, + t, + )!, + messageComposerMediaAttachmentTheme: + StreamMessageComposerMediaAttachmentThemeData.lerp( + _this.messageComposerMediaAttachmentTheme, + other.messageComposerMediaAttachmentTheme, + t, + )!, + messageComposerReplyAttachmentTheme: + StreamMessageComposerReplyAttachmentThemeData.lerp( + _this.messageComposerReplyAttachmentTheme, + other.messageComposerReplyAttachmentTheme, + t, + )!, + messageComposerUnsupportedAttachmentTheme: + StreamMessageComposerUnsupportedAttachmentThemeData.lerp( + _this.messageComposerUnsupportedAttachmentTheme, + other.messageComposerUnsupportedAttachmentTheme, + t, + )!, messageItemTheme: StreamMessageItemThemeData.lerp( _this.messageItemTheme, other.messageItemTheme, t, )!, - messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, textInputTheme: StreamTextInputThemeData.lerp( _this.textInputTheme, other.textInputTheme, @@ -283,8 +356,21 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiChipTheme == _this.emojiChipTheme && _other.jumpToUnreadButtonTheme == _this.jumpToUnreadButtonTheme && _other.listTileTheme == _this.listTileTheme && + _other.messageComposerAttachmentTheme == + _this.messageComposerAttachmentTheme && + _other.messageComposerEditMessageAttachmentTheme == + _this.messageComposerEditMessageAttachmentTheme && + _other.messageComposerFileAttachmentTheme == + _this.messageComposerFileAttachmentTheme && + _other.messageComposerLinkPreviewAttachmentTheme == + _this.messageComposerLinkPreviewAttachmentTheme && + _other.messageComposerMediaAttachmentTheme == + _this.messageComposerMediaAttachmentTheme && + _other.messageComposerReplyAttachmentTheme == + _this.messageComposerReplyAttachmentTheme && + _other.messageComposerUnsupportedAttachmentTheme == + _this.messageComposerUnsupportedAttachmentTheme && _other.messageItemTheme == _this.messageItemTheme && - _other.messageTheme == _this.messageTheme && _other.textInputTheme == _this.textInputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && _other.playbackSpeedToggleTheme == _this.playbackSpeedToggleTheme && @@ -326,8 +412,14 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiChipTheme, _this.jumpToUnreadButtonTheme, _this.listTileTheme, + _this.messageComposerAttachmentTheme, + _this.messageComposerEditMessageAttachmentTheme, + _this.messageComposerFileAttachmentTheme, + _this.messageComposerLinkPreviewAttachmentTheme, + _this.messageComposerMediaAttachmentTheme, + _this.messageComposerReplyAttachmentTheme, + _this.messageComposerUnsupportedAttachmentTheme, _this.messageItemTheme, - _this.messageTheme, _this.textInputTheme, _this.onlineIndicatorTheme, _this.playbackSpeedToggleTheme, 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 97c69198..58808d5a 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -14,8 +14,14 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_composer_attachment_theme.dart'; +import 'components/stream_message_composer_edit_message_attachment_theme.dart'; +import 'components/stream_message_composer_file_attachment_theme.dart'; +import 'components/stream_message_composer_link_preview_attachment_theme.dart'; +import 'components/stream_message_composer_media_attachment_theme.dart'; +import 'components/stream_message_composer_reply_attachment_theme.dart'; +import 'components/stream_message_composer_unsupported_attachment_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_playback_speed_toggle_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -123,12 +129,37 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMessageComposerAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerAttachmentThemeData get streamMessageComposerAttachmentTheme => + StreamMessageComposerAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerEditMessageAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerEditMessageAttachmentThemeData get streamMessageComposerEditMessageAttachmentTheme => + StreamMessageComposerEditMessageAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerFileAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerFileAttachmentThemeData get streamMessageComposerFileAttachmentTheme => + StreamMessageComposerFileAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerLinkPreviewAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerLinkPreviewAttachmentThemeData get streamMessageComposerLinkPreviewAttachmentTheme => + StreamMessageComposerLinkPreviewAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerMediaAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerMediaAttachmentThemeData get streamMessageComposerMediaAttachmentTheme => + StreamMessageComposerMediaAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerReplyAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerReplyAttachmentThemeData get streamMessageComposerReplyAttachmentTheme => + StreamMessageComposerReplyAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerUnsupportedAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerUnsupportedAttachmentThemeData get streamMessageComposerUnsupportedAttachmentTheme => + StreamMessageComposerUnsupportedAttachmentTheme.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); - /// Returns the [StreamTextInputThemeData] from the nearest ancestor. StreamTextInputThemeData get streamTextInputTheme => StreamTextInputTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart b/packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart new file mode 100644 index 00000000..976978cd --- /dev/null +++ b/packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +void main() { + Widget wrap(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: child, + ); + + Widget buildShrinkWrappingList() => ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: const [ + SizedBox(height: 20, child: Text('item 1')), + SizedBox(height: 20, child: Text('item 2')), + ], + ); + + // Captures every FlutterErrorDetails reported through onError into a + // local list and intentionally does *not* forward to the framework's + // handler. That keeps the cascade of layout errors from being dumped to + // the test console while still letting us inspect what fired. + List captureErrors() { + final caught = []; + final originalOnError = FlutterError.onError; + FlutterError.onError = caught.add; + addTearDown(() => FlutterError.onError = originalOnError); + return caught; + } + + group('StreamIntrinsicFlex bounded-cross-axis hint', () { + testWidgets( + 'augments the layout failure with a StreamIntrinsicBoundedCrossAxis hint', + (tester) async { + final caught = captureErrors(); + + await tester.pumpWidget( + wrap( + StreamIntrinsicColumn( + children: [buildShrinkWrappingList()], + ), + ), + ); + + expect(caught, isNotEmpty); + expect(caught.first.exception.toString(), contains('unbounded')); + + final info = caught.first.informationCollector?.call() ?? const []; + final infoText = info.map((node) => node.toString()).join('\n'); + expect(infoText, contains('StreamIntrinsicBoundedCrossAxis')); + expect(infoText, contains('flutter.dev/unbounded-constraints')); + }, + ); + + testWidgets( + 'lays out cleanly when the offending child is wrapped in ' + 'StreamIntrinsicBoundedCrossAxis', + (tester) async { + await tester.pumpWidget( + wrap( + StreamIntrinsicColumn( + children: [ + StreamIntrinsicBoundedCrossAxis(child: buildShrinkWrappingList()), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + }, + ); + + testWidgets( + 'hint is conditional and covers upstream culprits when the unbounded ' + 'constraint originates above this widget', + (tester) async { + // Wrapping the column in an UnconstrainedBox makes the column + // itself receive unbounded cross-axis constraints. The wrapper + // can't tell whether the unbounded came from us or from above, + // so the hint is phrased to cover both branches. + final caught = captureErrors(); + + await tester.pumpWidget( + wrap( + UnconstrainedBox( + child: StreamIntrinsicColumn( + children: [buildShrinkWrappingList()], + ), + ), + ), + ); + + expect(caught, isNotEmpty); + expect(caught.first.exception.toString(), contains('unbounded')); + + final info = caught.first.informationCollector?.call() ?? const []; + final infoText = info.map((node) => node.toString()).join('\n'); + // Local fix and upstream possibility are both surfaced. + expect(infoText, contains('StreamIntrinsicBoundedCrossAxis')); + expect(infoText, contains('UnconstrainedBox')); + }, + ); + }); +} diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png index c0410944..05179d73 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png index 39b3e6a4..edc64aab 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_custom_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_custom_matrix.png deleted file mode 100644 index e06fd90b..00000000 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_custom_matrix.png and /dev/null differ diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png index 88535340..86e0623c 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png index de630a3a..3c7a5adc 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart index 96cf2f6a..ebc2b047 100644 --- a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart +++ b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart @@ -18,10 +18,10 @@ void main() { GoldenTestScenario( name: 'full_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: null, ), ), @@ -29,10 +29,10 @@ void main() { GoldenTestScenario( name: 'full_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: () {}, ), ), @@ -40,11 +40,11 @@ void main() { GoldenTestScenario( name: 'full_with_image_no_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: null, ), ), @@ -52,33 +52,33 @@ void main() { GoldenTestScenario( name: 'full_with_image_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: () {}, ), ), ), GoldenTestScenario( - name: 'url_only_no_remove', + name: 'caption_only_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: null, ), ), ), GoldenTestScenario( - name: 'url_only_with_remove', + name: 'caption_only_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: () {}, ), ), @@ -96,10 +96,10 @@ void main() { GoldenTestScenario( name: 'full_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: null, ), brightness: Brightness.dark, @@ -108,10 +108,10 @@ void main() { GoldenTestScenario( name: 'full_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: () {}, ), brightness: Brightness.dark, @@ -120,11 +120,11 @@ void main() { GoldenTestScenario( name: 'full_with_image_no_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: null, ), brightness: Brightness.dark, @@ -133,35 +133,35 @@ void main() { GoldenTestScenario( name: 'full_with_image_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: () {}, ), brightness: Brightness.dark, ), ), GoldenTestScenario( - name: 'url_only_no_remove', + name: 'caption_only_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: null, ), brightness: Brightness.dark, ), ), GoldenTestScenario( - name: 'url_only_with_remove', + name: 'caption_only_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: () {}, ), brightness: Brightness.dark, @@ -188,6 +188,24 @@ const _kTransparentPixelPng = [ 0x42, 0x60, 0x82, // ]; +class _UrlCaption extends StatelessWidget { + const _UrlCaption({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(context.streamIcons.link, size: 12), + Flexible(child: Text(url)), + ], + ); + } +} + Widget _buildLinkPreviewInTheme( Widget linkPreview, { Brightness brightness = Brightness.light, diff --git a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart index ef2044fa..f33726f0 100644 --- a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart +++ b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart @@ -13,27 +13,27 @@ void main() { builder: () => GoldenTestGroup( scenarioConstraints: const BoxConstraints(maxWidth: 360), children: [ - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_no_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: null, - style: style, + direction: style, ), ), ), - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_with_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: () {}, - style: style, + direction: style, ), ), ), @@ -47,28 +47,28 @@ void main() { builder: () => GoldenTestGroup( scenarioConstraints: const BoxConstraints(maxWidth: 360), children: [ - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_no_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: null, - style: style, + direction: style, ), brightness: Brightness.dark, ), ), - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_with_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: () {}, - style: style, + direction: style, ), brightness: Brightness.dark, ), @@ -76,42 +76,6 @@ void main() { ], ), ); - - goldenTest( - 'renders custom theme style matrix', - fileName: 'message_composer_attachment_reply_custom_matrix', - builder: () => GoldenTestGroup( - scenarioConstraints: const BoxConstraints(maxWidth: 360), - children: [ - for (final style in ReplyStyle.values) - GoldenTestScenario( - name: style.name, - child: _buildReplyInTheme( - StreamMessageTheme( - data: const StreamMessageThemeData( - incoming: StreamMessageStyle( - backgroundColor: Colors.red, - replyIndicatorColor: Colors.green, - textColor: Colors.white, - ), - outgoing: StreamMessageStyle( - backgroundColor: Colors.blue, - replyIndicatorColor: Colors.yellow, - textColor: Colors.white, - ), - ), - child: MessageComposerReplyAttachment( - title: const Text('Reply to John Doe'), - subtitle: const Text('We had a great time during our holiday.'), - onRemovePressed: null, - style: style, - ), - ), - ), - ), - ], - ), - ); }); }