diff --git a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart index ba1497cb..2dcc307c 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart @@ -18,10 +18,18 @@ Widget buildStreamMessageAnnotationPlayground(BuildContext context) { final label = context.knobs.string( label: 'Label', - initialValue: 'Also sent in channel ·', + initialValue: 'Also sent in channel', description: 'The annotation label text.', ); + final showSeparator = context.knobs.boolean( + label: 'Show Separator', + initialValue: true, + description: + 'Whether to place a middle dot between the label and the trailing slot. ' + 'It is dropped automatically when the row wraps onto a second line.', + ); + final showLeading = context.knobs.boolean( label: 'Show Leading', initialValue: true, @@ -112,6 +120,7 @@ Widget buildStreamMessageAnnotationPlayground(BuildContext context) { : null, leading: showLeading ? Icon(leadingIcon.resolve(icons)) : null, label: Text(label), + separator: showSeparator ? StreamMessageAnnotation.separator : null, trailing: showTrailing ? Text(trailingText) : null, style: StreamMessageAnnotationStyle( spacing: StreamMessageLayoutProperty.all(spacing), @@ -202,7 +211,8 @@ class _AnnotationTypesSection extends StatelessWidget { subtitle: 'Bold label + regular-weight informational trailing timestamp.', child: StreamMessageAnnotation( leading: Icon(icons.bell), - label: const Text('Reminder set ·'), + label: const Text('Reminder set'), + separator: StreamMessageAnnotation.separator, trailing: const Text('In 2 hours'), ), ), @@ -212,7 +222,8 @@ class _AnnotationTypesSection extends StatelessWidget { child: StreamMessageAnnotation( onTap: () {}, leading: const Icon(Icons.translate), - label: const Text('Translated ·'), + label: const Text('Translated'), + separator: StreamMessageAnnotation.separator, trailing: const Text('Show original'), style: StreamMessageAnnotationStyle.from( trailingTextColor: colorScheme.textLink, @@ -225,7 +236,8 @@ class _AnnotationTypesSection extends StatelessWidget { child: StreamMessageAnnotation( onTap: () {}, leading: Icon(icons.arrowUp), - label: const Text('Also sent in channel ·'), + label: const Text('Also sent in channel'), + separator: StreamMessageAnnotation.separator, trailing: const Text('View'), style: StreamMessageAnnotationStyle.from( trailingTextColor: colorScheme.textLink, @@ -238,7 +250,8 @@ class _AnnotationTypesSection extends StatelessWidget { child: StreamMessageAnnotation( onTap: () {}, leading: Icon(icons.arrowUp), - label: const Text('Replied to a thread ·'), + label: const Text('Replied to a thread'), + separator: StreamMessageAnnotation.separator, trailing: const Text('View'), style: StreamMessageAnnotationStyle.from( trailingTextColor: colorScheme.textLink, @@ -252,7 +265,8 @@ class _AnnotationTypesSection extends StatelessWidget { 'own GestureDetector and leave onTap null.', child: StreamMessageAnnotation( leading: const Icon(Icons.translate), - label: const Text('Translated ·'), + label: const Text('Translated'), + separator: StreamMessageAnnotation.separator, trailing: GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, @@ -326,7 +340,8 @@ class _PresentationExample extends StatelessWidget { StreamMessageAnnotation( onTap: () {}, leading: Icon(icons.arrowUp), - label: const Text('Also sent in channel ·'), + label: const Text('Also sent in channel'), + separator: StreamMessageAnnotation.separator, trailing: const Text('View'), style: StreamMessageAnnotationStyle( trailingTextColor: _linkColor(colorScheme.textLink), @@ -458,7 +473,8 @@ class _RealWorldSection extends StatelessWidget { children: [ StreamMessageAnnotation( leading: Icon(icons.bell), - label: const Text('Reminder set ·'), + label: const Text('Reminder set'), + separator: StreamMessageAnnotation.separator, trailing: const Text('In 30 minutes'), ), StreamMessageBubble( @@ -476,7 +492,8 @@ class _RealWorldSection extends StatelessWidget { StreamMessageAnnotation( onTap: () {}, leading: Icon(icons.arrowUp), - label: const Text('Also sent in channel ·'), + label: const Text('Also sent in channel'), + separator: StreamMessageAnnotation.separator, trailing: const Text('View'), style: StreamMessageAnnotationStyle.from( trailingTextColor: colorScheme.textLink, diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 2ecf750c..8cc04e45 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -17,6 +17,11 @@ `voiceOffFill`, and `xmarkSmall`. - Added a `fix_data.yaml`, so deprecated members can be migrated with `dart fix --apply`. +- Added `StreamMessageAnnotation.separator`, a slot between `label` and `trailing` for a punctuation mark, along with a ready-made `StreamMessageAnnotation.separator` middle dot to pass to it. It defaults to null, so existing annotations are unaffected. It only renders alongside a `trailing`, is styled like the `label`, and is hidden from assistive technologies. + +### 🐞 Fixed + +- `StreamMessageAnnotation` no longer wraps its `label` mid-sentence when the row is too narrow, stranding the trailing action beside the label's last line. The trailing slot now moves below the label as a whole, and the separator — which separates nothing once the two are on different lines — is dropped. A label too wide even on its own still wraps across as many lines as it needs. ### 🔄 Changed 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 a23a8773..492ddae5 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 @@ -1,4 +1,7 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_message_annotation_theme.dart'; @@ -8,6 +11,7 @@ import '../../theme/primitives/stream_spacing.dart'; import '../../theme/semantics/stream_color_scheme.dart'; import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_theme_extensions.dart'; +import '../message_layout/stream_message_alignment.dart'; import '../message_layout/stream_message_layout.dart'; /// An annotation row for displaying contextual message annotations. @@ -21,8 +25,20 @@ import '../message_layout/stream_message_layout.dart'; /// widgets are automatically styled according to /// [StreamMessageAnnotationStyle]. /// -/// The visual order is always `[leading, label, trailing]` with configurable -/// spacing between them. Any slot that is null is omitted from the row. +/// The visual order is always `[leading, label, separator, trailing]` with +/// configurable spacing between them. Any slot that is null is omitted from +/// the row. +/// +/// ## Wrapping +/// +/// The row prefers to lay everything out on a single line. When the content +/// does not fit the available width, the [trailing] slot moves to a second +/// line as a whole instead of the [label] wrapping mid-sentence, and the +/// [separator] — which only reads as a separator between two things on the +/// same line — is dropped. +/// +/// A [label] that is too wide even on its own still wraps across as many +/// lines as it needs, with [trailing] placed below it. /// /// When [onTap] or [onLongPress] is provided, the entire row — including /// its padding — becomes tappable. This gives a forgiving hit target for @@ -55,7 +71,8 @@ import '../message_layout/stream_message_layout.dart'; /// StreamMessageAnnotation( /// onTap: () => openChannel(), /// leading: Icon(StreamIcons.arrowUpRight), -/// label: Text('Also sent in channel · '), +/// label: Text('Also sent in channel'), +/// separator: StreamMessageAnnotation.separator, /// trailing: Text('View'), /// style: StreamMessageAnnotationStyle.from( /// trailingTextColor: Theme.of(context).colorScheme.primary, @@ -71,13 +88,14 @@ import '../message_layout/stream_message_layout.dart'; class StreamMessageAnnotation extends StatelessWidget { /// Creates a message annotation row. /// - /// The [label] is required; [leading] and [trailing] are optional and - /// omitted from the row when null. When [onTap] or [onLongPress] is - /// provided, the entire row becomes tappable. + /// The [label] is required; [leading], [separator] and [trailing] are + /// optional and omitted from the row when null. When [onTap] or + /// [onLongPress] is provided, the entire row becomes tappable. StreamMessageAnnotation({ super.key, Widget? leading, required Widget label, + Widget? separator, Widget? trailing, VoidCallback? onTap, VoidCallback? onLongPress, @@ -85,12 +103,29 @@ class StreamMessageAnnotation extends StatelessWidget { }) : props = .new( leading: leading, label: label, + separator: separator, trailing: trailing, onTap: onTap, onLongPress: onLongPress, style: style, ); + /// The conventional separator between an annotation's label and its + /// trailing slot: a middle dot. + /// + /// Pass it to [StreamMessageAnnotationProps.separator] to get the + /// `label · trailing` reading used across the Stream SDKs: + /// + /// ```dart + /// StreamMessageAnnotation( + /// leading: Icon(StreamIcons.bell), + /// label: Text('Reminder set'), + /// separator: StreamMessageAnnotation.separator, + /// trailing: Text('in 2 hours'), + /// ) + /// ``` + static const Widget separator = Text('·'); + /// The properties that configure this annotation row. final StreamMessageAnnotationProps props; @@ -112,6 +147,7 @@ class StreamMessageAnnotationProps { const StreamMessageAnnotationProps({ this.leading, required this.label, + this.separator, this.trailing, this.onTap, this.onLongPress, @@ -132,6 +168,20 @@ class StreamMessageAnnotationProps { /// [StreamMessageAnnotationStyle.textColor]. final Widget label; + /// The widget placed between [label] and [trailing], typically a + /// [Text] holding a punctuation mark. + /// + /// Defaults to null — no separator. Pass + /// [StreamMessageAnnotation.separator] for the middle dot used across the + /// Stream SDKs. + /// + /// Only rendered when [trailing] is set and both fit on a single line: a + /// separator dangling at the end of a wrapped row separates nothing. + /// + /// Styled like [label], and hidden from assistive technologies — a + /// separator is punctuation, not content. + final Widget? separator; + /// The trailing widget, typically a tappable link or a secondary label /// (e.g., a timestamp). /// @@ -189,14 +239,14 @@ class DefaultStreamMessageAnnotation extends StatelessWidget { ); } - final labelWidget = Flexible( - child: AnimatedDefaultTextStyle( - style: effectiveTextStyle.copyWith(color: effectiveTextColor), - duration: kThemeChangeDuration, - child: props.label, - ), + final labelStyle = effectiveTextStyle.copyWith(color: effectiveTextColor); + final labelWidget = AnimatedDefaultTextStyle( + style: labelStyle, + duration: kThemeChangeDuration, + child: props.label, ); + Widget? separatorWidget; Widget? trailingWidget; if (props.trailing case final trailing?) { final effectiveTrailingTextStyle = resolve((s) => s?.trailingTextStyle); @@ -207,14 +257,29 @@ class DefaultStreamMessageAnnotation extends StatelessWidget { duration: kThemeChangeDuration, child: trailing, ); + + // A separator with nothing after it separates nothing, so it only + // exists alongside a trailing slot. + if (props.separator case final separator?) { + separatorWidget = ExcludeSemantics( + child: AnimatedDefaultTextStyle( + style: labelStyle, + duration: kThemeChangeDuration, + child: separator, + ), + ); + } } final child = Padding( padding: effectivePadding, - child: Row( - mainAxisSize: MainAxisSize.min, + child: _AnnotationRow( spacing: effectiveSpacing, - children: [?leadingWidget, labelWidget, ?trailingWidget], + alignment: layout.alignment, + leading: leadingWidget, + label: labelWidget, + separator: separatorWidget, + trailing: trailingWidget, ), ); @@ -231,6 +296,292 @@ class DefaultStreamMessageAnnotation extends StatelessWidget { } } +/// The slots laid out by [_AnnotationRow], in visual order. +enum _AnnotationSlot { leading, label, separator, trailing } + +/// Lays out an annotation's slots on a single line, falling back to two lines +/// when they don't fit. +/// +/// A plain [Row] with a flexible label solves the overflow by wrapping the +/// label's text, which strands the trailing action beside the label's last +/// line. This lays out `[leading, label, separator, trailing]` as two atomic +/// groups instead: when the line is too narrow, `trailing` moves below +/// `[leading, label]` in full, and `separator` is dropped because it no +/// longer sits between anything. +/// +/// The second line is indented to the label's edge, so it reads as a +/// continuation of the row rather than a new one. For an end-aligned message +/// both lines are flushed to the end edge instead. +class _AnnotationRow extends SlottedMultiChildRenderObjectWidget<_AnnotationSlot, RenderBox> { + const _AnnotationRow({ + required this.spacing, + required this.alignment, + required this.label, + this.leading, + this.separator, + this.trailing, + }); + + /// The gap between slots, and between the two lines when the row wraps. + final double spacing; + + /// Which edge the row is aligned to, mirroring the message it annotates. + final StreamMessageAlignment alignment; + + final Widget label; + final Widget? leading; + final Widget? separator; + final Widget? trailing; + + @override + Iterable<_AnnotationSlot> get slots => _AnnotationSlot.values; + + @override + Widget? childForSlot(_AnnotationSlot slot) => switch (slot) { + _AnnotationSlot.leading => leading, + _AnnotationSlot.label => label, + _AnnotationSlot.separator => separator, + _AnnotationSlot.trailing => trailing, + }; + + @override + _RenderAnnotationRow createRenderObject(BuildContext context) { + return _RenderAnnotationRow( + spacing: spacing, + alignment: alignment, + textDirection: Directionality.of(context), + ); + } + + @override + void updateRenderObject(BuildContext context, covariant _RenderAnnotationRow renderObject) { + renderObject + ..spacing = spacing + ..alignment = alignment + ..textDirection = Directionality.of(context); + } +} + +/// The resolved geometry of one [_RenderAnnotationRow] layout pass. +/// +/// [offsets] is keyed by slot and holds the position of every slot that takes +/// part in the layout. A slot missing from the map is not painted — which is +/// how the separator disappears on a wrapped row. +typedef _RowGeometry = ({Size size, Map<_AnnotationSlot, Offset> offsets}); + +class _RenderAnnotationRow extends RenderBox with SlottedContainerRenderObjectMixin<_AnnotationSlot, RenderBox> { + _RenderAnnotationRow({ + required this._spacing, + required this._alignment, + required this._textDirection, + }); + + double get spacing => _spacing; + double _spacing; + set spacing(double value) { + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + StreamMessageAlignment get alignment => _alignment; + StreamMessageAlignment _alignment; + set alignment(StreamMessageAlignment value) { + if (_alignment == value) return; + _alignment = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + RenderBox? get _leading => childForSlot(_AnnotationSlot.leading); + RenderBox get _label => childForSlot(_AnnotationSlot.label)!; + RenderBox? get _separator => childForSlot(_AnnotationSlot.separator); + RenderBox? get _trailing => childForSlot(_AnnotationSlot.trailing); + + // The slots painted by the last layout pass, in paint order. Rebuilt on + // every layout, so it also decides what can be hit-tested. + final _paintOrder = <_AnnotationSlot>[]; + + /// Measures every slot and resolves where each one goes. + /// + /// [layoutChild] is [ChildLayoutHelper.layoutChild] for a real layout pass + /// and [ChildLayoutHelper.dryLayoutChild] for a dry one, so both passes + /// share this single source of truth. + _RowGeometry _computeGeometry(BoxConstraints constraints, ChildLayouter layoutChild) { + final leading = _leading; + final separator = _separator; + final trailing = _trailing; + + const unbounded = BoxConstraints(); + final leadingSize = leading == null ? Size.zero : layoutChild(leading, unbounded); + final separatorSize = separator == null ? Size.zero : layoutChild(separator, unbounded); + final trailingSize = trailing == null ? Size.zero : layoutChild(trailing, unbounded); + + // The label starts after the leading slot, and everything from the + // separator onwards trails it. Absent slots claim no gap. + final gutter = leading == null ? 0.0 : leadingSize.width + spacing; + final separatorGap = separator == null ? 0.0 : spacing; + final trailingGap = trailing == null ? 0.0 : spacing; + final tail = separatorGap + separatorSize.width + trailingGap + trailingSize.width; + + // How wide the label wants to be before it starts wrapping its text. + final labelWidth = _label.getMaxIntrinsicWidth(double.infinity); + + final maxWidth = constraints.maxWidth; + // Moving the trailing slot down is only a fix when there is one; a label + // that overflows on its own gains nothing from a second line. + final wraps = trailing != null && maxWidth.isFinite && gutter + labelWidth + tail > maxWidth; + + if (!wraps) { + final labelSize = layoutChild(_label, BoxConstraints(maxWidth: math.max(0, maxWidth - gutter - tail))); + + final height = [ + leadingSize.height, + labelSize.height, + separatorSize.height, + trailingSize.height, + ].reduce(math.max); + + // Slots are centered against the tallest one. + double dy(Size size) => (height - size.height) / 2; + + final labelEnd = gutter + labelSize.width; + return ( + size: constraints.constrain(Size(labelEnd + tail, height)), + offsets: { + if (leading != null) _AnnotationSlot.leading: Offset(0, dy(leadingSize)), + _AnnotationSlot.label: Offset(gutter, dy(labelSize)), + if (separator != null) _AnnotationSlot.separator: Offset(labelEnd + separatorGap, dy(separatorSize)), + if (trailing != null) + _AnnotationSlot.trailing: Offset( + labelEnd + separatorGap + separatorSize.width + trailingGap, + dy(trailingSize), + ), + }, + ); + } + + // Wrapped: `[leading, label]` on the first line, `trailing` on the + // second. The label keeps the full width to itself and wraps its own + // text if it still doesn't fit. + final labelSize = layoutChild(_label, BoxConstraints(maxWidth: math.max(0, maxWidth - gutter))); + final firstLine = Size(gutter + labelSize.width, math.max(leadingSize.height, labelSize.height)); + + // An end-aligned row hugs the end edge, so indenting the second line + // would push it away from the message it belongs to. + final alignsToEnd = alignment == StreamMessageAlignment.end; + final indent = alignsToEnd ? 0.0 : gutter; + final secondLine = Size(indent + trailingSize.width, trailingSize.height); + + final width = math.min(maxWidth, math.max(firstLine.width, secondLine.width)); + final size = constraints.constrain(Size(width, firstLine.height + spacing + secondLine.height)); + + // Runs shorter than the row are pushed to whichever edge the row hugs. + double runStart(Size line) => alignsToEnd ? size.width - line.width : 0; + + final firstLineStart = runStart(firstLine); + return ( + size: size, + offsets: { + if (leading != null) + _AnnotationSlot.leading: Offset(firstLineStart, (firstLine.height - leadingSize.height) / 2), + _AnnotationSlot.label: Offset(firstLineStart + gutter, (firstLine.height - labelSize.height) / 2), + _AnnotationSlot.trailing: Offset(runStart(secondLine) + indent, firstLine.height + spacing), + }, + ); + } + + @override + void performLayout() { + final geometry = _computeGeometry(constraints, ChildLayoutHelper.layoutChild); + size = geometry.size; + + _paintOrder + ..clear() + ..addAll(geometry.offsets.keys); + + final flip = textDirection == TextDirection.rtl; + for (final MapEntry(key: slot, value: offset) in geometry.offsets.entries) { + final child = childForSlot(slot)!; + final parentData = child.parentData! as BoxParentData; + parentData.offset = switch (flip) { + true => Offset(size.width - offset.dx - child.size.width, offset.dy), + false => offset, + }; + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeGeometry(constraints, ChildLayoutHelper.dryLayoutChild).size; + } + + @override + double computeMinIntrinsicWidth(double height) { + // The narrowest the row can get is the wrapped form: the widest of its + // two lines, each squeezed as far as its content allows. + final leading = _leading; + final gutter = leading == null ? 0.0 : leading.getMinIntrinsicWidth(height) + spacing; + final firstLine = gutter + _label.getMinIntrinsicWidth(height); + final secondLine = _trailing?.getMinIntrinsicWidth(height) ?? 0.0; + return math.max(firstLine, secondLine); + } + + @override + double computeMaxIntrinsicWidth(double height) { + // The widest the row can get is the single-line form. + var width = _label.getMaxIntrinsicWidth(height); + for (final slot in [_leading, _separator, _trailing]) { + if (slot == null) continue; + width += slot.getMaxIntrinsicWidth(height) + spacing; + } + return width; + } + + @override + double computeMinIntrinsicHeight(double width) => _intrinsicHeight(width); + + @override + double computeMaxIntrinsicHeight(double width) => _intrinsicHeight(width); + + // Whether the row is one line or two depends on the width it is given, so + // its height follows straight from a dry pass at that width. + double _intrinsicHeight(double width) { + return _computeGeometry(BoxConstraints(maxWidth: width), ChildLayoutHelper.dryLayoutChild).size.height; + } + + @override + void paint(PaintingContext context, Offset offset) { + for (final slot in _paintOrder) { + final child = childForSlot(slot)!; + final parentData = child.parentData! as BoxParentData; + context.paintChild(child, offset + parentData.offset); + } + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + for (final slot in _paintOrder.reversed) { + final child = childForSlot(slot)!; + final parentData = child.parentData! as BoxParentData; + final hit = result.addWithPaintOffset( + offset: parentData.offset, + position: position, + hitTest: (result, transformed) => child.hitTest(result, position: transformed), + ); + if (hit) return true; + } + return false; + } +} + class _StreamMessageAnnotationDefaults extends StreamMessageAnnotationStyle { _StreamMessageAnnotationDefaults(this._context); diff --git a/packages/stream_core_flutter/test/components/message/stream_message_annotation_test.dart b/packages/stream_core_flutter/test/components/message/stream_message_annotation_test.dart new file mode 100644 index 00000000..08152e89 --- /dev/null +++ b/packages/stream_core_flutter/test/components/message/stream_message_annotation_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/chat.dart'; + +/// Verifies how [StreamMessageAnnotation] lays its slots out: on a single line +/// while they fit, and as two lines — with the separator dropped — when they +/// don't. +void main() { + // The test font draws every glyph as a square of the font size, so the + // annotation below measures ~470px on one line. These two widths sit either + // side of that, with [wrapped] still wide enough for the label alone. + const roomy = 600.0; + const wrapped = 400.0; + + Widget wrap({ + required Widget child, + double width = roomy, + StreamMessageAlignment alignment = StreamMessageAlignment.start, + TextDirection textDirection = TextDirection.ltr, + }) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Theme( + data: ThemeData(extensions: [StreamTheme()]), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: StreamMessageLayout( + data: StreamMessageLayoutData(alignment: alignment), + // Aligning rather than sizing keeps the width bounded while + // letting the annotation shrink-wrap, so its own size can be + // measured. + child: Align(alignment: AlignmentDirectional.centerStart, child: child), + ), + ), + ), + ), + ), + ); + } + + Widget subject({ + Widget? separator = StreamMessageAnnotation.separator, + Widget? trailing = const Text('Show original'), + String label = 'Translated from English', + VoidCallback? onTap, + }) { + return StreamMessageAnnotation( + onTap: onTap, + leading: const Icon(Icons.translate), + label: Text(label), + separator: separator, + trailing: trailing, + ); + } + + Rect rectOf(WidgetTester tester, Finder finder) { + final box = tester.renderObject(finder); + return box.localToGlobal(Offset.zero) & box.size; + } + + Rect rowRect(WidgetTester tester) => rectOf(tester, find.byType(StreamMessageAnnotation)); + Rect labelRect(WidgetTester tester) => rectOf(tester, find.text('Translated from English')); + Rect trailingRect(WidgetTester tester) => rectOf(tester, find.text('Show original')); + + group('single line', () { + testWidgets('keeps every slot on one line, in order', (tester) async { + await tester.pumpWidget(wrap(child: subject())); + + final label = labelRect(tester); + final separator = rectOf(tester, find.text('·')); + final trailing = trailingRect(tester); + + // Slots are centered against the tallest one rather than top-aligned, + // so their centers are what line up. + expect(label.center.dy, moreOrLessEquals(separator.center.dy, epsilon: 1)); + expect(label.center.dy, moreOrLessEquals(trailing.center.dy, epsilon: 1)); + + expect(separator.left, greaterThanOrEqualTo(label.right)); + expect(trailing.left, greaterThanOrEqualTo(separator.right)); + }); + + testWidgets('omits the separator when none is given', (tester) async { + await tester.pumpWidget(wrap(child: subject(separator: null))); + + expect(find.text('·'), findsNothing); + expect(trailingRect(tester).left, greaterThanOrEqualTo(labelRect(tester).right)); + }); + + testWidgets('omits the separator when there is nothing to separate', (tester) async { + await tester.pumpWidget(wrap(child: subject(trailing: null))); + + expect(find.text('·'), findsNothing); + }); + + testWidgets('gives the separator room of its own', (tester) async { + await tester.pumpWidget(wrap(child: subject())); + final withSeparator = rowRect(tester).width; + + await tester.pumpWidget(wrap(child: subject(separator: null))); + final withoutSeparator = rowRect(tester).width; + + expect(withSeparator, greaterThan(withoutSeparator)); + }); + + testWidgets('hides the separator from assistive technologies', (tester) async { + await tester.pumpWidget(wrap(child: subject())); + + expect(find.bySemanticsLabel('Translated from English'), findsOneWidget); + expect(find.bySemanticsLabel('Show original'), findsOneWidget); + expect(find.bySemanticsLabel('·'), findsNothing); + }); + }); + + group('wrapped', () { + testWidgets('moves the trailing slot to its own line', (tester) async { + await tester.pumpWidget(wrap(width: wrapped, child: subject())); + + expect(trailingRect(tester).top, greaterThanOrEqualTo(labelRect(tester).bottom)); + }); + + testWidgets('keeps the label on a single line', (tester) async { + await tester.pumpWidget(wrap(child: subject())); + final unwrapped = labelRect(tester).height; + + await tester.pumpWidget(wrap(width: wrapped, child: subject())); + + expect(labelRect(tester).height, unwrapped); + }); + + testWidgets('drops the separator', (tester) async { + await tester.pumpWidget(wrap(width: wrapped, child: subject())); + final withSeparator = rowRect(tester).size; + + await tester.pumpWidget(wrap(width: wrapped, child: subject(separator: null))); + final withoutSeparator = rowRect(tester).size; + + // A dropped separator claims neither width nor a gap, so a wrapped row + // measures the same with and without one. + expect(withSeparator, withoutSeparator); + }); + + testWidgets('indents the trailing slot to the label for a start-aligned message', (tester) async { + await tester.pumpWidget(wrap(width: wrapped, child: subject())); + + expect(trailingRect(tester).left, moreOrLessEquals(labelRect(tester).left, epsilon: 1)); + }); + + testWidgets('flushes both lines to the end edge for an end-aligned message', (tester) async { + await tester.pumpWidget( + wrap(width: wrapped, alignment: StreamMessageAlignment.end, child: subject()), + ); + + final row = rowRect(tester); + expect(labelRect(tester).right, moreOrLessEquals(row.right, epsilon: 1)); + expect(trailingRect(tester).right, moreOrLessEquals(row.right, epsilon: 1)); + }); + + testWidgets('wraps the label itself when it has a line to spare', (tester) async { + await tester.pumpWidget(wrap(child: subject())); + final oneLine = labelRect(tester).height; + + await tester.pumpWidget(wrap(width: 200, child: subject())); + + final label = labelRect(tester); + // The label took more than one line, and the trailing slot still sits + // below all of them. + expect(label.height, greaterThan(oneLine)); + expect(trailingRect(tester).top, greaterThanOrEqualTo(label.bottom)); + }); + + testWidgets('taps on the second line still reach the row', (tester) async { + var tapped = 0; + await tester.pumpWidget( + wrap( + width: wrapped, + child: subject(onTap: () => tapped++), + ), + ); + + await tester.tap(find.text('Show original')); + + expect(tapped, 1); + }); + }); + + group('rtl', () { + testWidgets('mirrors the single-line order', (tester) async { + await tester.pumpWidget(wrap(textDirection: TextDirection.rtl, child: subject())); + + final label = labelRect(tester); + final separator = rectOf(tester, find.text('·')); + final trailing = trailingRect(tester); + + expect(separator.right, lessThanOrEqualTo(label.left)); + expect(trailing.right, lessThanOrEqualTo(separator.left)); + }); + + testWidgets('mirrors the wrapped indent', (tester) async { + await tester.pumpWidget( + wrap(width: wrapped, textDirection: TextDirection.rtl, child: subject()), + ); + + final label = labelRect(tester); + final trailing = trailingRect(tester); + + expect(trailing.top, greaterThanOrEqualTo(label.bottom)); + expect(trailing.right, moreOrLessEquals(label.right, epsilon: 1)); + }); + }); +}