mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(mobile): add swipe-to-reply message gesture (#8436)
## 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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'message_swipe_reply.dart';
|
||||
import 'agent_reply_handoff.dart';
|
||||
import 'huddle_timeline.dart';
|
||||
import '../../shared/widgets/modal_presentation.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<MessageGalleryScrollNotification>(
|
||||
final content = NotificationListener<MessageGalleryScrollNotification>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
234
mobile/lib/features/channels/message_swipe_reply.dart
Normal file
234
mobile/lib/features/channels/message_swipe_reply.dart
Normal file
@@ -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<TimelineMessage>? 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<double?>(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<void>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<MessageGalleryScrollNotification>(
|
||||
final content = NotificationListener<MessageGalleryScrollNotification>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = <MethodCall>[];
|
||||
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<DecoratedBox>(
|
||||
find.byKey(const ValueKey('swipe-reply-circle')),
|
||||
);
|
||||
expect(
|
||||
(partialCircle.decoration as BoxDecoration).color,
|
||||
Colors.transparent,
|
||||
);
|
||||
final ring = tester.widget<CircularProgressIndicator>(
|
||||
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<DecoratedBox>(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<Text>(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<ThreadDetailPage>(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<Opacity>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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 = <MethodChannel>[];
|
||||
final configs = <Map<Object?, Object?>>[];
|
||||
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<Object?, Object?>.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 = <Rect>[];
|
||||
final widths = <double>[];
|
||||
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:
|
||||
'\n',
|
||||
),
|
||||
testWidgets('outgoing gallery starts at the right and mirrors its spacing', (
|
||||
tester,
|
||||
) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
final channels = <MethodChannel>[];
|
||||
final configs = <Map<Object?, Object?>>[];
|
||||
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<Object?, Object?>.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 = <Rect>[];
|
||||
final widths = <double>[];
|
||||
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:
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
);
|
||||
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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user