From bbb5183f33df3c6a5f7c587cc41233bc555cd15c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 18 Oct 2026 18:19:21 -0400 Subject: [PATCH] feat(mobile): add swipe-to-reply message gesture (#8436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Swipe right on channel and thread messages to open the existing reply destination. Adds a centred accent progress ring, Reply label, threshold haptic/pulse, drag resistance, cancellation, and reduced-motion support. Retreating below readiness re-arms feedback. Includes a separate formatting-only fix required by main’s updated Dart formatter. ### Related issue Related message styling: #8281. No matching swipe-to-reply issue found. ### Testing - Full pre-push checks passed, including mobile analysis/tests and file-size checks. - Widget coverage: routing, repeated threshold crossings, cancellation, resistance, alignment, and centred scaling. - Release preview installed on iPhone with the existing profile/community; Kenny tested and confirmed the interaction. Draft: native preview verification predates the rebase onto main’s Flutter upgrade. No native gesture recording attached. --------- Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page.dart | 1 + .../channel_detail_page/message_bubble.dart | 14 +- .../channels/message_swipe_reply.dart | 234 ++++++++++++++++++ .../features/channels/thread_detail_page.dart | 1 + .../thread_detail_page/thread_message.dart | 14 +- .../channels/channel_detail_page_test.dart | 2 + .../swipe_reply_tests.dart | 226 +++++++++++++++++ .../message_carousel_preview_cases.dart | 126 +++++----- 8 files changed, 552 insertions(+), 66 deletions(-) create mode 100644 mobile/lib/features/channels/message_swipe_reply.dart create mode 100644 mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 67bd1e0623..6d3a2487d7 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,4 @@ +import 'message_swipe_reply.dart'; import 'agent_reply_handoff.dart'; import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 6e4c812de4..13d525ef2d 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -40,6 +40,7 @@ class _MessageBubble extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final swipeContentKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); final hasLocalReplies = ref.watch( threadLocalRepliesProvider( @@ -132,7 +133,7 @@ class _MessageBubble extends HookConsumerWidget { ); } - return NotificationListener( + final content = NotificationListener( onNotification: (notification) { mediaOffset.value = notification.offset; return true; @@ -342,6 +343,7 @@ class _MessageBubble extends HookConsumerWidget { ), animate: typingHandoff, child: MessageContent( + key: swipeContentKey, content: message.content, bubble: true, hasReactions: @@ -468,6 +470,16 @@ class _MessageBubble extends HookConsumerWidget { ), ), ); + return MessageSwipeReply( + contentKey: swipeContentKey, + message: message, + allMessages: allMessages, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + child: content, + ); } } diff --git a/mobile/lib/features/channels/message_swipe_reply.dart b/mobile/lib/features/channels/message_swipe_reply.dart new file mode 100644 index 0000000000..e4448dfbe1 --- /dev/null +++ b/mobile/lib/features/channels/message_swipe_reply.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:math' show sin, pi; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/buzz_icons.dart'; +import '../../shared/theme/theme.dart'; +import 'thread_detail_page.dart'; +import 'timeline_message.dart'; + +/// Reveals the existing reply action as a message is dragged to the right. +class MessageSwipeReply extends HookConsumerWidget { + final TimelineMessage message; + final List? allMessages; + final String channelId; + final String? currentPubkey; + final bool isMember; + final bool isArchived; + final Widget child; + + /// The visible content, excluding author metadata and reply/reaction footers. + final GlobalKey contentKey; + + const MessageSwipeReply({ + super.key, + required this.message, + required this.allMessages, + required this.channelId, + required this.currentPubkey, + required this.isMember, + required this.isArchived, + required this.child, + required this.contentKey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final offset = useAnimationController(upperBound: double.infinity); + final pulse = useAnimationController( + duration: const Duration(milliseconds: 180), + ); + final dragDistance = useRef(0.0); + final layoutKey = useMemoized(GlobalKey.new); + final contentCenter = useState(null); + final didHaptic = useRef(false); + final cancelled = useRef(false); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final messages = allMessages; + if (messages == null || message.isSystem) return child; + + void reset() { + pulse.stop(); + pulse.value = 0; + if (reducedMotion) { + offset.value = 0; + } else { + unawaited( + offset.animateWith( + ScrollSpringSimulation( + const SpringDescription(mass: 1, stiffness: 500, damping: 36), + offset.value, + 0, + 0, + ), + ), + ); + } + } + + // Flutter ends an accepted drag on PointerCancel too. Preserve the raw + // cancellation signal so an interrupted gesture cannot open a reply. + return Listener( + onPointerCancel: (_) => cancelled.value = true, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + onHorizontalDragStart: (_) { + final contentBox = contentKey.currentContext?.findRenderObject(); + final layoutBox = layoutKey.currentContext?.findRenderObject(); + if (contentBox is RenderBox && layoutBox is RenderBox) { + contentCenter.value = layoutBox + .globalToLocal( + contentBox.localToGlobal(contentBox.size.center(Offset.zero)), + ) + .dy; + } + offset.stop(); + pulse.stop(); + pulse.value = 0; + offset.value = 0; + dragDistance.value = 0; + didHaptic.value = false; + cancelled.value = false; + }, + onHorizontalDragUpdate: (details) { + dragDistance.value = (dragDistance.value + details.delta.dx).clamp( + 0.0, + double.infinity, + ); + // Keep pulling beyond readiness, with increasing rubber-band resistance. + final excess = dragDistance.value - 72; + offset.value = excess <= 0 + ? dragDistance.value + : 72 + 40 * excess / (excess + 80); + if (offset.value >= 72 && !didHaptic.value) { + didHaptic.value = true; + unawaited(HapticFeedback.mediumImpact()); + if (!reducedMotion) unawaited(pulse.forward(from: 0)); + } + if (offset.value < 72) { + didHaptic.value = false; + pulse.stop(); + pulse.value = 0; + } + }, + onHorizontalDragCancel: reset, + onHorizontalDragEnd: (_) { + final reply = !cancelled.value && offset.value >= 72; + reset(); + if (!reply) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: messages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + child: AnimatedBuilder( + animation: Listenable.merge([offset, pulse]), + child: child, + builder: (context, child) { + final progress = (offset.value / 72).clamp(0.0, 1.0); + final accent = context.appColors.accent; + final filledForeground = + ThemeData.estimateBrightnessForColor(accent) == Brightness.dark + ? Colors.white + : Colors.black; + return Stack( + key: layoutKey, + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + if (progress > 0) + Positioned( + left: 0, + top: contentCenter.value ?? 0, + width: Grid.lg, + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: ExcludeSemantics( + child: Opacity( + opacity: progress, + child: Transform.scale( + scale: reducedMotion + ? 1 + : (0.5 + 0.5 * progress) * + (1 + 0.12 * sin(pi * pulse.value)), + alignment: Alignment.center, + child: SizedBox( + key: const ValueKey('swipe-reply-feedback'), + width: Grid.lg, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + dimension: Grid.lg, + child: DecoratedBox( + key: const ValueKey('swipe-reply-circle'), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: progress == 1 + ? accent + : Colors.transparent, + ), + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator( + value: progress, + strokeWidth: 2, + strokeAlign: -1, + color: accent, + backgroundColor: Colors.transparent, + ), + Icon( + BuzzIcons.messageSquareReply, + size: 22, + color: progress == 1 + ? filledForeground + : context.colors.primary, + ), + ], + ), + ), + ), + const SizedBox(height: Grid.quarter), + Text( + 'Reply', + textAlign: TextAlign.center, + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + Transform.translate( + offset: Offset(offset.value, 0), + child: child, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0541f97229..3bb517008f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,3 +1,4 @@ +import 'message_swipe_reply.dart'; import '../../shared/widgets/ios_scroll_to_top.dart'; import 'message_footer_layout.dart'; import 'reply_summary_link.dart'; diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 7bbec9def0..89bf0ef415 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -41,6 +41,7 @@ class _ThreadMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final swipeContentKey = useMemoized(GlobalKey.new, const []); final mediaOffset = useMessageMediaOffset(message.content, message.tags); final pk = message.pubkey.toLowerCase(); final ownMessage = currentPubkey?.toLowerCase() == pk; @@ -133,7 +134,7 @@ class _ThreadMessage extends HookConsumerWidget { alpha: _landingHighlightOpacity * highlightProgress, ); - return NotificationListener( + final content = NotificationListener( onNotification: (notification) { mediaOffset.value = notification.offset; return true; @@ -310,6 +311,7 @@ class _ThreadMessage extends HookConsumerWidget { ), ), MessageContent( + key: swipeContentKey, content: message.content, bubble: true, hasReactions: @@ -434,5 +436,15 @@ class _ThreadMessage extends HookConsumerWidget { ), ), ); + return MessageSwipeReply( + contentKey: swipeContentKey, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + child: content, + ); } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index dcd6fb89c7..5b375c84bd 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -98,6 +98,7 @@ part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart'; part 'channel_detail_page_test/bubble_tests.dart'; +part 'channel_detail_page_test/swipe_reply_tests.dart'; part 'channel_detail_page_test/review_avatar_tests.dart'; part 'channel_detail_page_test/retention_tests.dart'; @@ -568,6 +569,7 @@ void main() { presenceTests(); actionRowTests(); bubbleLayoutTests(); + swipeReplyTests(); reviewAvatarTests(); channelRetentionTests(); setUp(() async { diff --git a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart new file mode 100644 index 0000000000..c5d9e08d70 --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart @@ -0,0 +1,226 @@ +part of '../channel_detail_page_test.dart'; + +void swipeReplyTests() { + for (final thread in [false, true]) { + testWidgets('right drag opens existing reply destination, thread=$thread', ( + tester, + ) async { + final events = [ + _textMsg( + id: threadIdRoot, + pubkey: 'alice', + content: 'Swipe this message\nA second line\nAnd a third line', + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.first, + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey( + ValueKey( + '${thread ? 'thread-message-row' : 'message-row'}-$threadIdRoot', + ), + ); + final initial = tester.getTopLeft(row); + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') haptics.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final gesture = await tester.startGesture(tester.getCenter(row)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(40, 0)); + await tester.pump(); + expect(tester.getTopLeft(row).dx, greaterThan(initial.dx)); + expect(haptics, isEmpty); + final partialCircle = tester.widget( + find.byKey(const ValueKey('swipe-reply-circle')), + ); + expect( + (partialCircle.decoration as BoxDecoration).color, + Colors.transparent, + ); + final ring = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('swipe-reply-circle')), + matching: find.byType(CircularProgressIndicator), + ), + ); + expect(ring.strokeWidth, 2); + expect(ring.value, inExclusiveRange(0, 1)); + await gesture.moveBy(const Offset(45, 0)); + await tester.pump(); + expect(haptics, hasLength(1)); + await gesture.moveBy(const Offset(10, 0)); + await tester.pump(); + expect(haptics, hasLength(1)); + final armedPosition = tester.getTopLeft(row); + await gesture.moveBy(const Offset(160, 0)); + await tester.pump(); + final resistedPosition = tester.getTopLeft(row); + expect(resistedPosition.dx, greaterThan(armedPosition.dx)); + expect(resistedPosition.dx, lessThan(armedPosition.dx + 40)); + final circle = find.byKey(const ValueKey('swipe-reply-circle')); + expect(tester.getRect(circle).left, initial.dx); + final fill = + tester.widget(circle).decoration as BoxDecoration; + expect(fill.color, tester.element(circle).appColors.accent); + expect(find.text('Reply'), findsOneWidget); + final centerBeforePulse = tester.getCenter( + find.byKey(const ValueKey('swipe-reply-feedback')), + ); + await tester.pump(const Duration(milliseconds: 60)); + expect( + tester.getCenter(find.byKey(const ValueKey('swipe-reply-feedback'))), + centerBeforePulse, + ); + expect(tester.getRect(circle).width, greaterThan(Grid.lg)); + await tester.pump(const Duration(milliseconds: 180)); + expect(tester.getRect(circle).width, Grid.lg); + expect(haptics, hasLength(1)); + final feedback = find.byKey(const ValueKey('swipe-reply-feedback')); + final content = find.descendant( + of: row, + matching: find.byType(MessageContent), + ); + expect(tester.getCenter(feedback).dy, tester.getCenter(content).dy); + expect( + tester.getCenter(find.text('Reply')).dx, + tester.getCenter(circle).dx, + ); + expect( + tester.widget(find.text('Reply')).style?.color, + tester.element(circle).colors.onSurfaceVariant, + ); + // Retreat below readiness without returning to rest, then cross again. + await gesture.moveBy(const Offset(-210, 0)); + await tester.pump(); + expect(tester.getTopLeft(row).dx, greaterThan(initial.dx)); + await gesture.moveBy(const Offset(45, 0)); + await tester.pump(); + expect(haptics, hasLength(2)); + await tester.pump(const Duration(milliseconds: 60)); + expect(tester.getRect(circle).width, greaterThan(Grid.lg)); + await tester.pump(const Duration(milliseconds: 180)); + expect(tester.getRect(circle).width, Grid.lg); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect( + tester + .widget(find.byType(ThreadDetailPage)) + .threadHead + .id, + threadIdRoot, + ); + // A thread message must push a reply route too, rather than doing nothing. + expect( + Navigator.of(tester.element(find.byType(ThreadDetailPage))).canPop(), + isTrue, + ); + }); + } + + for (final movement in [ + const Offset(45, 0), + const Offset(-100, 0), + const Offset(0, -100), + ]) { + testWidgets('non-reply drag $movement does not navigate', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'swipe-cancel', + pubkey: 'alice', + content: 'Keep reading', + ), + ], + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-swipe-cancel')); + await tester.drag(row, movement); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsNothing); + }); + } + + for (final reducedMotion in [false, true]) { + testWidgets('cancelled drag resets feedback, reduced=$reducedMotion', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + disableAnimations: reducedMotion, + messages: [ + _textMsg(id: 'cancel', pubkey: 'alice', content: 'Cancel drag'), + ], + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-cancel')); + final origin = tester.getTopLeft(row); + final gesture = await tester.startGesture(tester.getCenter(row)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(90, 0)); + await tester.pump(); + final icon = find.byIcon(BuzzIcons.messageSquareReply); + expect( + tester + .widget( + find.ancestor(of: icon, matching: find.byType(Opacity)).first, + ) + .opacity, + 1, + ); + await gesture.cancel(); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(row), origin); + expect(find.byType(ThreadDetailPage), findsNothing); + }); + } + + testWidgets('crossing threshold then retreating cancels reply', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'retreat', pubkey: 'alice', content: 'Keep reading'), + ], + ), + ); + await tester.pumpAndSettle(); + final row = find.byKey(const ValueKey('message-row-retreat')); + final gesture = await tester.startGesture(tester.getCenter(row)); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(90, 0)); + await gesture.moveBy(const Offset(-70, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsNothing); + }); +} diff --git a/mobile/test/features/channels/message_carousel_preview_cases.dart b/mobile/test/features/channels/message_carousel_preview_cases.dart index 1dff030bf3..9ab476de19 100644 --- a/mobile/test/features/channels/message_carousel_preview_cases.dart +++ b/mobile/test/features/channels/message_carousel_preview_cases.dart @@ -105,70 +105,68 @@ void carouselPreviewTests() { variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); - testWidgets( - 'outgoing gallery starts at the right and mirrors its spacing', - (tester) async { - final messenger = tester.binding.defaultBinaryMessenger; - final channels = []; - final configs = >[]; - messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( - call, - ) async { - if (call.method == 'create') { - final id = (call.arguments as Map)['id']; - final channel = MethodChannel('buzz/ios_media_carousel/$id'); - channels.add(channel); - messenger.setMockMethodCallHandler(channel, (call) async { - if (call.method == 'configure') { - configs.add(Map.from(call.arguments as Map)); - } - return null; - }); - } - return null; - }); - addTearDown(() { - messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); - for (final channel in channels) { - messenger.setMockMethodCallHandler(channel, null); - } - }); - final counts = []; - final widths = []; - for (final own in [false, true]) { - await tester.pumpWidget( - _testable( - Padding( - padding: const EdgeInsets.symmetric(horizontal: 50), - child: MessageContent( - key: ValueKey(own), - ownMessage: own, - bubble: true, - mediaCarouselTrailingOverflow: Grid.gutter, - content: - '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', - ), + testWidgets('outgoing gallery starts at the right and mirrors its spacing', ( + tester, + ) async { + final messenger = tester.binding.defaultBinaryMessenger; + final channels = []; + final configs = >[]; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final id = (call.arguments as Map)['id']; + final channel = MethodChannel('buzz/ios_media_carousel/$id'); + channels.add(channel); + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'configure') { + configs.add(Map.from(call.arguments as Map)); + } + return null; + }); + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(SystemChannels.platform_views, null); + for (final channel in channels) { + messenger.setMockMethodCallHandler(channel, null); + } + }); + final counts = []; + final widths = []; + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: MessageContent( + key: ValueKey(own), + ownMessage: own, + bubble: true, + mediaCarouselTrailingOverflow: Grid.gutter, + content: + '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', ), ), - ); - await tester.pumpAndSettle(); - counts.add( - tester.getRect( - find.byKey(const ValueKey('message-media-carousel-count')), - ), - ); - widths.add( - tester - .getSize(find.byKey(const ValueKey('message-media-carousel'))) - .width, - ); - expect(configs.last['rtl'], own); - } - expect(widths[0], widths[1]); - expect(counts[0].left, closeTo(800 - counts[1].right, 0.1)); - await tester.pumpWidget(const SizedBox()); - expect(tester.takeException(), isNull); - }, - variant: TargetPlatformVariant.only(TargetPlatform.iOS), - ); + ), + ); + await tester.pumpAndSettle(); + counts.add( + tester.getRect( + find.byKey(const ValueKey('message-media-carousel-count')), + ), + ); + widths.add( + tester + .getSize(find.byKey(const ValueKey('message-media-carousel'))) + .width, + ); + expect(configs.last['rtl'], own); + } + expect(widths[0], widths[1]); + expect(counts[0].left, closeTo(800 - counts[1].right, 0.1)); + await tester.pumpWidget(const SizedBox()); + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); }