From ccf812e1dcdedb9c3235fadc30e8e77ab9315b46 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Sat, 1 Aug 2026 10:14:13 +0200 Subject: [PATCH] wip --- packages/stream_core_flutter/CHANGELOG.md | 1 + packages/stream_core_flutter/lib/chat.dart | 1 + .../message/stream_message_annotation.dart | 15 +- .../message/stream_message_metadata.dart | 17 +- .../message/stream_message_replies.dart | 9 +- .../message_layout/stream_message_layout.dart | 75 +++++++-- .../stream_message_presentation.dart | 23 +++ .../stream_message_presentation_test.dart | 156 ++++++++++++++++++ .../stream_message_layout_test.dart | 130 +++++++++++++++ 9 files changed, 408 insertions(+), 19 deletions(-) create mode 100644 packages/stream_core_flutter/lib/src/components/message_layout/stream_message_presentation.dart create mode 100644 packages/stream_core_flutter/test/components/message/stream_message_presentation_test.dart create mode 100644 packages/stream_core_flutter/test/components/message_layout/stream_message_layout_test.dart diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index cb43cf63..69a2081e 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -14,6 +14,7 @@ - Added `isFloating` to the default `StreamButton` constructor — the floating (elevated) appearance was previously reachable only through `StreamButton.icon`. Labelled buttons now get the same treatment: elevation for every type, plus a `backgroundElevation1` fill for `outline` and `ghost`. - Added `StreamElevation` — the four elevation levels of the design system as logical pixels, for passing to `Material.elevation` or a component theme's `elevation` field. Like `StreamRadius` and `StreamSpacing` it is a theme primitive: reachable as `StreamTheme.elevation` or `context.streamElevation`, overridable per theme through the `StreamTheme` constructor, and lerped on theme transitions. `StreamElevation.none` is a fixed `0` rather than a themeable level, so "flat" cannot be redefined as elevated. `StreamAvatar` and `StreamButton` now resolve their elevations from it instead of hard-coded numbers; the rendered values are unchanged. - Added `chipStyle` to `StreamReactionsThemeData` for overriding the per-reaction chip appearance (background, size, etc.); it is merged over the default reaction chip style. +- Added `StreamMessagePresentation` and `StreamMessageLayoutData.presentation`, describing whether a message is drawn inline in the list (`standard`) or as a preview above a scrim (`preview`, e.g. the long-press message-actions modal). Read it with `StreamMessageLayout.presentationOf(context)`, or resolve per-presentation styling through `StreamMessageLayoutProperty.resolveWith`. For `preview`, the default metadata (username, timestamp, edited, status), annotation (text, icon, trailing) and replies-label colors now resolve to `StreamColorScheme.textOnAccent` so they stay legible against `StreamColorScheme.backgroundScrim`. Also added `StreamMessageLayoutData.copyWith`. ### 🐛 Bug Fixes diff --git a/packages/stream_core_flutter/lib/chat.dart b/packages/stream_core_flutter/lib/chat.dart index 998af37f..7efaa6d5 100644 --- a/packages/stream_core_flutter/lib/chat.dart +++ b/packages/stream_core_flutter/lib/chat.dart @@ -33,6 +33,7 @@ export 'src/components/message_layout/stream_message_channel_kind.dart'; export 'src/components/message_layout/stream_message_content_kind.dart'; export 'src/components/message_layout/stream_message_layout.dart'; export 'src/components/message_layout/stream_message_list_kind.dart'; +export 'src/components/message_layout/stream_message_presentation.dart'; export 'src/components/message_layout/stream_message_stack_position.dart'; export 'src/components/reaction/stream_reaction_picker.dart'; export 'src/components/reaction/stream_reactions.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart index 911fbd71..a23a8773 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart @@ -240,14 +240,23 @@ class _StreamMessageAnnotationDefaults extends StreamMessageAnnotationStyle { late final StreamTextTheme _textTheme = _context.streamTextTheme; late final StreamSpacing _spacing = _context.streamSpacing; + // Resolves to [standard] for inline messages, and to white for previews, + // where the message sits on a scrim and annotations need the extra contrast. + StreamMessageLayoutProperty _presentationAware(Color standard) => .resolveWith( + (layout) => switch (layout.presentation) { + .standard => standard, + .preview => _colorScheme.textOnAccent, + }, + ); + @override StreamMessageLayoutProperty get textStyle => .all(_textTheme.metadataEmphasis); @override - StreamMessageLayoutProperty get textColor => .all(_colorScheme.textPrimary); + StreamMessageLayoutProperty get textColor => _presentationAware(_colorScheme.textPrimary); @override - StreamMessageLayoutProperty get iconColor => .all(_colorScheme.textPrimary); + StreamMessageLayoutProperty get iconColor => _presentationAware(_colorScheme.textPrimary); @override StreamMessageLayoutProperty get iconSize => .all(16); @@ -262,5 +271,5 @@ class _StreamMessageAnnotationDefaults extends StreamMessageAnnotationStyle { StreamMessageLayoutProperty get trailingTextStyle => .all(_textTheme.metadataDefault); @override - StreamMessageLayoutProperty get trailingTextColor => .all(_colorScheme.textPrimary); + StreamMessageLayoutProperty get trailingTextColor => _presentationAware(_colorScheme.textPrimary); } diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart index 46430f5b..8705bc7d 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart @@ -229,29 +229,38 @@ class _StreamMessageMetadataDefaults extends StreamMessageMetadataStyle { late final StreamTextTheme _textTheme = _context.streamTextTheme; late final StreamSpacing _spacing = _context.streamSpacing; + // Resolves to [standard] for inline messages, and to white for previews, + // where the message sits on a scrim and metadata needs the extra contrast. + StreamMessageLayoutProperty _presentationAware(Color standard) => .resolveWith( + (layout) => switch (layout.presentation) { + .standard => standard, + .preview => _colorScheme.textOnAccent, + }, + ); + @override StreamMessageLayoutProperty get usernameTextStyle => .all(_textTheme.metadataEmphasis); @override - StreamMessageLayoutProperty get usernameColor => .all(_colorScheme.textSecondary); + StreamMessageLayoutProperty get usernameColor => _presentationAware(_colorScheme.textSecondary); @override StreamMessageLayoutProperty get timestampTextStyle => .all(_textTheme.metadataDefault); @override - StreamMessageLayoutProperty get timestampColor => .all(_colorScheme.textTertiary); + StreamMessageLayoutProperty get timestampColor => _presentationAware(_colorScheme.textTertiary); @override StreamMessageLayoutProperty get editedTextStyle => .all(_textTheme.metadataDefault); @override - StreamMessageLayoutProperty get editedColor => .all(_colorScheme.textTertiary); + StreamMessageLayoutProperty get editedColor => _presentationAware(_colorScheme.textTertiary); @override StreamMessageLayoutProperty get statusTextStyle => .all(_textTheme.metadataDefault); @override - StreamMessageLayoutProperty get statusColor => .all(_colorScheme.textTertiary); + StreamMessageLayoutProperty get statusColor => _presentationAware(_colorScheme.textTertiary); @override StreamMessageLayoutProperty get statusIconSize => .all(16); diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart index 5d5b43f2..ea3fe662 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart @@ -376,8 +376,15 @@ class _StreamMessageRepliesDefaults extends StreamMessageRepliesStyle { @override StreamMessageLayoutProperty get labelTextStyle => .all(_textTheme.captionEmphasis); + // Turns white for previews, where the message sits on a scrim and the label + // needs the extra contrast. @override - StreamMessageLayoutProperty get labelColor => .all(_colorScheme.textLink); + StreamMessageLayoutProperty get labelColor => .resolveWith( + (layout) => switch (layout.presentation) { + .standard => _colorScheme.textLink, + .preview => _colorScheme.textOnAccent, + }, + ); @override StreamMessageLayoutProperty get spacing => .all(_spacing.xs); diff --git a/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_layout.dart b/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_layout.dart index c597e2f4..886f9d77 100644 --- a/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_layout.dart +++ b/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_layout.dart @@ -4,6 +4,7 @@ import 'stream_message_alignment.dart'; import 'stream_message_channel_kind.dart'; import 'stream_message_content_kind.dart'; import 'stream_message_list_kind.dart'; +import 'stream_message_presentation.dart'; import 'stream_message_stack_position.dart'; // The aspect of a [StreamMessageLayoutData] that a widget depends on. @@ -26,6 +27,9 @@ enum _StreamMessageLayoutAspect { // The content kind (standard / singleAttachment / jumbomoji). contentKind, + + // The presentation (standard / preview). + presentation, } /// Provides [StreamMessageLayoutData] to descendant widgets. @@ -47,11 +51,14 @@ enum _StreamMessageLayoutAspect { /// changes). /// * [contentKindOf] — returns only the content kind (ignores other /// field changes). +/// * [presentationOf] — returns only the presentation (ignores other +/// field changes). /// /// When no [StreamMessageLayout] is found in the tree, a default layout of /// [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] + /// [StreamMessageChannelKind.group] + [StreamMessageListKind.channel] + -/// [StreamMessageContentKind.standard] is returned. +/// [StreamMessageContentKind.standard] + +/// [StreamMessagePresentation.standard] is returned. /// /// {@tool snippet} /// @@ -98,7 +105,8 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { /// If there is no [StreamMessageLayout] in scope, a default layout of /// [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] + /// [StreamMessageChannelKind.group] + [StreamMessageListKind.channel] + - /// [StreamMessageContentKind.standard] is returned. + /// [StreamMessageContentKind.standard] + + /// [StreamMessagePresentation.standard] is returned. static StreamMessageLayoutData of(BuildContext context) => _of(context); static StreamMessageLayoutData _of(BuildContext context, [_StreamMessageLayoutAspect? aspect]) { @@ -227,6 +235,21 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { return _of(context, _StreamMessageLayoutAspect.contentKind).contentKind; } + /// Returns [StreamMessageLayoutData.presentation] from the nearest + /// [StreamMessageLayout] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessageLayoutData.presentation] property of the + /// ancestor [StreamMessageLayout] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessageLayoutData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamMessagePresentation presentationOf(BuildContext context) { + return _of(context, _StreamMessageLayoutAspect.presentation).presentation; + } + @override bool updateShouldNotify(StreamMessageLayout oldWidget) => data != oldWidget.data; @@ -243,6 +266,7 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { .channelKind => data.channelKind != oldWidget.data.channelKind, .listKind => data.listKind != oldWidget.data.listKind, .contentKind => data.contentKind != oldWidget.data.contentKind, + .presentation => data.presentation != oldWidget.data.presentation, }; }, ); @@ -253,10 +277,10 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { /// Combines positional properties — [alignment] (start vs end), /// [stackPosition] (single, top, middle, bottom) — with environmental /// context — [channelKind] (direct vs group), [listKind] (channel vs -/// thread) — and content classification — [contentKind] (standard, -/// singleAttachment, jumbomoji) — into a single value that -/// [StreamMessageLayoutProperty] resolvers use to compute -/// layout-dependent styling. +/// thread), [presentation] (standard vs preview) — and content +/// classification — [contentKind] (standard, singleAttachment, jumbomoji) — +/// into a single value that [StreamMessageLayoutProperty] resolvers use to +/// compute layout-dependent styling. /// /// {@tool snippet} /// @@ -270,6 +294,7 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { /// channelKind: StreamMessageChannelKind.group, /// listKind: StreamMessageListKind.channel, /// contentKind: StreamMessageContentKind.singleAttachment, +/// presentation: StreamMessagePresentation.standard, /// ); /// /// print(layout.alignment); // StreamMessageAlignment.end @@ -277,6 +302,7 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { /// print(layout.channelKind); // StreamMessageChannelKind.group /// print(layout.listKind); // StreamMessageListKind.channel /// print(layout.contentKind); // StreamMessageContentKind.singleAttachment +/// print(layout.presentation); // StreamMessagePresentation.standard /// ``` /// {@end-tool} /// @@ -287,21 +313,24 @@ class StreamMessageLayout extends InheritedModel<_StreamMessageLayoutAspect> { /// * [StreamMessageChannelKind], the kind of channel the message is displayed in. /// * [StreamMessageListKind], the kind of list the message is displayed in. /// * [StreamMessageContentKind], the kind of content the message carries. +/// * [StreamMessagePresentation], how the message is presented to the user. /// * [StreamMessageLayoutProperty], which resolves values from this context. @immutable class StreamMessageLayoutData { /// Creates message layout data. /// /// Defaults to a start-aligned, standalone message in a group channel list - /// with standard content ([StreamMessageAlignment.start] + + /// with standard content, presented inline ([StreamMessageAlignment.start] + /// [StreamMessageStackPosition.single] + [StreamMessageChannelKind.group] + - /// [StreamMessageListKind.channel] + [StreamMessageContentKind.standard]). + /// [StreamMessageListKind.channel] + [StreamMessageContentKind.standard] + + /// [StreamMessagePresentation.standard]). const StreamMessageLayoutData({ this.alignment = .start, this.stackPosition = .single, this.channelKind = .group, this.listKind = .channel, this.contentKind = .standard, + this.presentation = .standard, }); /// The horizontal alignment of the message. @@ -319,6 +348,29 @@ class StreamMessageLayoutData { /// The kind of content this message carries. final StreamMessageContentKind contentKind; + /// How this message is presented to the user. + final StreamMessagePresentation presentation; + + /// Returns a copy of this [StreamMessageLayoutData] with the given fields + /// replaced with new values. + StreamMessageLayoutData copyWith({ + StreamMessageAlignment? alignment, + StreamMessageStackPosition? stackPosition, + StreamMessageChannelKind? channelKind, + StreamMessageListKind? listKind, + StreamMessageContentKind? contentKind, + StreamMessagePresentation? presentation, + }) { + return StreamMessageLayoutData( + alignment: alignment ?? this.alignment, + stackPosition: stackPosition ?? this.stackPosition, + channelKind: channelKind ?? this.channelKind, + listKind: listKind ?? this.listKind, + contentKind: contentKind ?? this.contentKind, + presentation: presentation ?? this.presentation, + ); + } + @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -327,13 +379,14 @@ class StreamMessageLayoutData { other.stackPosition == stackPosition && other.channelKind == channelKind && other.listKind == listKind && - other.contentKind == contentKind; + other.contentKind == contentKind && + other.presentation == presentation; } @override - int get hashCode => Object.hash(alignment, stackPosition, channelKind, listKind, contentKind); + int get hashCode => Object.hash(alignment, stackPosition, channelKind, listKind, contentKind, presentation); @override String toString() => - 'StreamMessageLayoutData(alignment: $alignment, stackPosition: $stackPosition, channelKind: $channelKind, listKind: $listKind, contentKind: $contentKind)'; + 'StreamMessageLayoutData(alignment: $alignment, stackPosition: $stackPosition, channelKind: $channelKind, listKind: $listKind, contentKind: $contentKind, presentation: $presentation)'; } diff --git a/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_presentation.dart b/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_presentation.dart new file mode 100644 index 00000000..bba2bc40 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_layout/stream_message_presentation.dart @@ -0,0 +1,23 @@ +/// How a message is presented to the user. +/// +/// Used by [StreamMessageLayoutData] to let descendant widgets adapt their +/// appearance based on what the message is drawn on top of — for example, +/// switching secondary text and icons to white when the message is previewed +/// above a scrim. +/// +/// See also: +/// +/// * [StreamMessageLayoutData], which carries this value. +/// * [StreamMessageLayout], the [InheritedModel] that provides it. +enum StreamMessagePresentation { + /// Rendered inline in the message list, on the app background. + standard, + + /// Rendered as a preview above a scrim, for example in the long-press + /// message-actions modal. + /// + /// Secondary foreground — metadata, annotations, status icons and the + /// thread-reply label — switches to `StreamColorScheme.textOnAccent` to keep + /// sufficient contrast against `StreamColorScheme.backgroundScrim`. + preview, +} diff --git a/packages/stream_core_flutter/test/components/message/stream_message_presentation_test.dart b/packages/stream_core_flutter/test/components/message/stream_message_presentation_test.dart new file mode 100644 index 00000000..8aee79a8 --- /dev/null +++ b/packages/stream_core_flutter/test/components/message/stream_message_presentation_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/chat.dart'; + +/// Verifies that the metadata, annotation and replies defaults switch their +/// secondary foreground to [StreamColorScheme.textOnAccent] when the message is +/// presented as a preview — i.e. drawn on top of the modal scrim. +void main() { + Widget wrap({ + required Widget child, + required StreamMessagePresentation presentation, + Brightness brightness = Brightness.light, + }) { + return MaterialApp( + home: Theme( + data: ThemeData( + brightness: brightness, + extensions: [StreamTheme(brightness: brightness)], + ), + child: Scaffold( + body: StreamMessageLayout( + data: StreamMessageLayoutData(presentation: presentation), + child: child, + ), + ), + ), + ); + } + + // The color a Text actually paints with, after the ambient DefaultTextStyle. + Color? textColorOf(WidgetTester tester, String text) { + final context = tester.element(find.text(text)); + return DefaultTextStyle.of(context).style.color; + } + + // The color an Icon actually paints with, after the ambient IconTheme. + Color? iconColorOf(WidgetTester tester, IconData icon) { + final context = tester.element(find.byIcon(icon)); + return IconTheme.of(context).color; + } + + StreamColorScheme colorSchemeOf(WidgetTester tester) { + return StreamTheme.of(tester.element(find.byType(Scaffold))).colorScheme; + } + + group('StreamMessageMetadata', () { + Widget subject(StreamMessagePresentation presentation, {Brightness brightness = Brightness.light}) { + return wrap( + presentation: presentation, + brightness: brightness, + child: StreamMessageMetadata( + username: const Text('Alice'), + status: const Icon(Icons.done_all), + timestamp: const Text('09:41'), + edited: const Text('Edited'), + ), + ); + } + + testWidgets('uses muted colors for a standard presentation', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.standard)); + final colorScheme = colorSchemeOf(tester); + + expect(textColorOf(tester, 'Alice'), colorScheme.textSecondary); + expect(textColorOf(tester, '09:41'), colorScheme.textTertiary); + expect(textColorOf(tester, 'Edited'), colorScheme.textTertiary); + expect(iconColorOf(tester, Icons.done_all), colorScheme.textTertiary); + }); + + testWidgets('uses the on-scrim color for a preview presentation', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.preview)); + final colorScheme = colorSchemeOf(tester); + + expect(textColorOf(tester, 'Alice'), colorScheme.textOnAccent); + expect(textColorOf(tester, '09:41'), colorScheme.textOnAccent); + expect(textColorOf(tester, 'Edited'), colorScheme.textOnAccent); + expect(iconColorOf(tester, Icons.done_all), colorScheme.textOnAccent); + }); + + testWidgets('uses the on-scrim color for a preview presentation in dark mode', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.preview, brightness: Brightness.dark)); + final colorScheme = colorSchemeOf(tester); + + // textOnAccent stays white in dark mode, unlike textOnInverse. + expect(colorScheme.textOnAccent, const Color(0xFFFFFFFF)); + expect(textColorOf(tester, '09:41'), colorScheme.textOnAccent); + expect(iconColorOf(tester, Icons.done_all), colorScheme.textOnAccent); + }); + + testWidgets('an explicit style still wins over the preview default', (tester) async { + const green = Color(0xFF4CAF50); + + await tester.pumpWidget( + wrap( + presentation: StreamMessagePresentation.preview, + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + style: StreamMessageMetadataStyle.from(timestampColor: green), + ), + ), + ); + + expect(textColorOf(tester, '09:41'), green); + }); + }); + + group('StreamMessageAnnotation', () { + Widget subject(StreamMessagePresentation presentation) { + return wrap( + presentation: presentation, + child: StreamMessageAnnotation( + leading: const Icon(Icons.push_pin), + label: const Text('Pinned by You'), + trailing: const Text('View'), + ), + ); + } + + testWidgets('uses primary text colors for a standard presentation', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.standard)); + final colorScheme = colorSchemeOf(tester); + + expect(textColorOf(tester, 'Pinned by You'), colorScheme.textPrimary); + expect(textColorOf(tester, 'View'), colorScheme.textPrimary); + expect(iconColorOf(tester, Icons.push_pin), colorScheme.textPrimary); + }); + + testWidgets('uses the on-scrim color for a preview presentation', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.preview)); + final colorScheme = colorSchemeOf(tester); + + expect(textColorOf(tester, 'Pinned by You'), colorScheme.textOnAccent); + expect(textColorOf(tester, 'View'), colorScheme.textOnAccent); + expect(iconColorOf(tester, Icons.push_pin), colorScheme.textOnAccent); + }); + }); + + group('StreamMessageReplies', () { + Widget subject(StreamMessagePresentation presentation) { + return wrap( + presentation: presentation, + child: StreamMessageReplies(label: const Text('2 replies')), + ); + } + + testWidgets('uses the link color for a standard presentation', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.standard)); + expect(textColorOf(tester, '2 replies'), colorSchemeOf(tester).textLink); + }); + + testWidgets('uses the on-scrim color for a preview presentation', (tester) async { + await tester.pumpWidget(subject(StreamMessagePresentation.preview)); + expect(textColorOf(tester, '2 replies'), colorSchemeOf(tester).textOnAccent); + }); + }); +} diff --git a/packages/stream_core_flutter/test/components/message_layout/stream_message_layout_test.dart b/packages/stream_core_flutter/test/components/message_layout/stream_message_layout_test.dart new file mode 100644 index 00000000..d8c934e2 --- /dev/null +++ b/packages/stream_core_flutter/test/components/message_layout/stream_message_layout_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/chat.dart'; + +void main() { + group('StreamMessageLayoutData', () { + test('defaults to a standard presentation', () { + const data = StreamMessageLayoutData(); + expect(data.presentation, StreamMessagePresentation.standard); + }); + + test('copyWith replaces only the given fields', () { + const data = StreamMessageLayoutData( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.middle, + channelKind: StreamMessageChannelKind.direct, + listKind: StreamMessageListKind.thread, + contentKind: StreamMessageContentKind.jumbomoji, + ); + + final copy = data.copyWith(presentation: StreamMessagePresentation.preview); + + expect(copy.presentation, StreamMessagePresentation.preview); + expect(copy.alignment, data.alignment); + expect(copy.stackPosition, data.stackPosition); + expect(copy.channelKind, data.channelKind); + expect(copy.listKind, data.listKind); + expect(copy.contentKind, data.contentKind); + }); + + test('copyWith without arguments returns an equal value', () { + const data = StreamMessageLayoutData(presentation: StreamMessagePresentation.preview); + expect(data.copyWith(), data); + }); + + test('presentation participates in equality and hashCode', () { + const standard = StreamMessageLayoutData(); + const preview = StreamMessageLayoutData(presentation: StreamMessagePresentation.preview); + + expect(standard, isNot(preview)); + expect(standard.hashCode, isNot(preview.hashCode)); + expect(preview, const StreamMessageLayoutData(presentation: StreamMessagePresentation.preview)); + }); + + test('toString includes the presentation', () { + const data = StreamMessageLayoutData(presentation: StreamMessagePresentation.preview); + expect(data.toString(), contains('presentation: StreamMessagePresentation.preview')); + }); + }); + + group('StreamMessageLayout.presentationOf', () { + testWidgets('returns standard when no layout is in scope', (tester) async { + late StreamMessagePresentation presentation; + + await tester.pumpWidget( + Builder( + builder: (context) { + presentation = StreamMessageLayout.presentationOf(context); + return const SizedBox.shrink(); + }, + ), + ); + + expect(presentation, StreamMessagePresentation.standard); + }); + + testWidgets('returns the presentation of the nearest layout', (tester) async { + late StreamMessagePresentation presentation; + + await tester.pumpWidget( + StreamMessageLayout( + data: const StreamMessageLayoutData(presentation: StreamMessagePresentation.preview), + child: Builder( + builder: (context) { + presentation = StreamMessageLayout.presentationOf(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(presentation, StreamMessagePresentation.preview); + }); + + testWidgets('does not rebuild dependents when an unrelated field changes', (tester) async { + // The child is const so the only thing that can trigger a rebuild is the + // InheritedModel aspect, which is what this test is about. + Widget buildSubject(StreamMessageLayoutData data) { + return StreamMessageLayout(data: data, child: const _PresentationProbe()); + } + + _probeBuildCount = 0; + addTearDown(() => _probeBuildCount = 0); + + await tester.pumpWidget(buildSubject(const StreamMessageLayoutData())); + expect(_probeBuildCount, 1); + + // Changing the alignment must not rebuild a presentation-only dependent. + await tester.pumpWidget( + buildSubject(const StreamMessageLayoutData(alignment: StreamMessageAlignment.end)), + ); + expect(_probeBuildCount, 1); + + await tester.pumpWidget( + buildSubject( + const StreamMessageLayoutData( + alignment: StreamMessageAlignment.end, + presentation: StreamMessagePresentation.preview, + ), + ), + ); + expect(_probeBuildCount, 2); + }); + }); +} + +// How often [_PresentationProbe] has rebuilt. +var _probeBuildCount = 0; + +// Counts how often it rebuilds while depending only on the presentation aspect. +class _PresentationProbe extends StatelessWidget { + const _PresentationProbe(); + + @override + Widget build(BuildContext context) { + StreamMessageLayout.presentationOf(context); + _probeBuildCount++; + return const SizedBox.shrink(); + } +}