mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
Follow-up to [#9356 gallery video selection](https://github.com/block/buzz/pull/9356), merged as [ef57fec4d](ef57fec4d2). It makes two changes that came out of review. ### 1. Remove automatic stills for videos without poster metadata #9356 generated a still for every rendered video bubble or stack card that had no NIP-71 poster. To do that it downloaded the **whole video** (up to the 100 MB limit), grabbed a frame and threw the file away. Desktop (buzz-app) never writes `image`/`thumb` on video attachments, so this covered every desktop-sent video, not only old mobile messages. Before #9356 those bubbles were a static placeholder that used no network. The cost on a real timeline: - A four-card stack could pull up to about 400 MB just for stills, before anyone pressed play. - When the user did open a video, playback started its own separate download of the same file, and the poster download kept competing for bandwidth. - A failed, cancelled or stalled download saved nothing, so every remount (scroll back, swipe back in a stack) started the full GET again. Cached stills from non-relay URLs were also dropped and fetched again after the cache's seven-day expiry. This PR deletes that path: `video_poster.dart` and its test, plus the watchers and `generatedPoster` branches in `video_preview.dart` and `media_stack.dart`. Posterless videos are back to base behavior: "Video attachment" in a bubble, the plain surface on a stack card, and the source snapshot in the native viewer. The rest of #9356 stays: queued local stills (`prepareVideoPoster` on the local file, no network), the remote-poster preview in the native viewer, the picker changes and swipe dismissal. `NativeVideoFiles._cancelTimeouts` was added for poster disposal. No remaining test depends on it, so it goes back to base too. If stills for desktop-sent videos are still wanted, a bounded follow-up is to generate and cache the still from the file that playback already downloaded, after the user opens it. That adds no network. ### 2. Route `.3gpp` and `.3g2` picks as videos The Video row is gone from the attachment menu, so `_isPickedVideo` is now the only thing that classifies path-only picker results. Its extension list missed `3gpp` and `3g2`. The recent-media grid returns the original library file, and PHPicker exports 3GPP2 as `.3g2`, so those videos were queued as images and then rejected by `uploadImage`. Before #9356, the explicit Video action uploaded them through `uploadVideo`, which transcodes to MP4. ### Pareto The change only removes work: no extra network or decoding for posterless videos, and no change for videos that carry a poster. The one thing we give up is the still on posterless videos that #9356 added and base never had. ### Validation - Added test rows: `clip.3gpp` and `clip.3g2` in the existing mixed-gallery composer journey. Both now queue as video. - Full mobile `flutter test` passes on [518af3a0d](518af3a0d2): 4,688 passed, 71 skipped. `flutter analyze` is clean. - I didn't run an app journey for this fix. It brings the posterless paths back to base code, and #9356's own validation covers the rest. Review report: `buzz://message?channel=e71f9a2a-ac46-473d-a24e-5e16fd8cb7b1&id=bcae52d59c255add1a927e0135441a1c58115b6aa6cda8d9ff7e58d47a627a87` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
691 lines
25 KiB
Dart
691 lines
25 KiB
Dart
part of 'message_content_test.dart';
|
|
|
|
void stackJourneyTests() {
|
|
testWidgets('mixed stacks keep video order and return from the fallback player', (
|
|
tester,
|
|
) async {
|
|
const clip = 'https://example.com/clip.mp4';
|
|
const photo = 'https://example.com/photo.png';
|
|
const poster = 'https://example.com/second-poster.png';
|
|
final requested = <String>[];
|
|
final client = MockClient((request) async {
|
|
requested.add(request.url.toString());
|
|
return http.Response.bytes(
|
|
base64Decode(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEElEQVR4nGNgaPj/H4xhDABS0gn5PEa22gAAAABJRU5ErkJggg==',
|
|
),
|
|
200,
|
|
);
|
|
});
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
const MessageContent(
|
|
bubble: true,
|
|
content:
|
|
'\n\n',
|
|
tags: [
|
|
[
|
|
'imeta',
|
|
'url https://example.com/second.mov',
|
|
'm video/quicktime',
|
|
'image $poster',
|
|
],
|
|
],
|
|
),
|
|
disableAnimations: true,
|
|
overrides: [mediaHttpClientProvider.overrideWithValue(client)],
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(requested, contains(photo));
|
|
expect(requested, contains(poster));
|
|
expect(requested, isNot(contains(clip)));
|
|
expect(requested, isNot(contains('https://example.com/second.mov')));
|
|
final stack = find.byKey(const ValueKey('message-media-stack'));
|
|
final spoken = find.byKey(const ValueKey('message-media-stack-semantics'));
|
|
expect(find.text('3 attachments'), findsOneWidget);
|
|
expect(
|
|
find.byKey(const ValueKey('message-media-stack-video:$clip')),
|
|
findsOneWidget,
|
|
);
|
|
expect(tester.widget<Semantics>(spoken).properties.label, 'Clip');
|
|
expect(
|
|
tester.widget<Semantics>(spoken).properties.value,
|
|
'Attachment 1 of 3',
|
|
);
|
|
await tester.tap(stack);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.byKey(const ValueKey('message-media-video-viewer')),
|
|
findsOneWidget,
|
|
);
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('message-media-video-viewer-close')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(tester.widget<Semantics>(spoken).properties.label, 'Clip');
|
|
await tester.drag(stack, const Offset(-160, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.widget<Semantics>(spoken).properties.label, 'Photo');
|
|
await tester.tap(stack);
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(MediaImageViewerPage), findsOneWidget);
|
|
expect(
|
|
tester
|
|
.widget<MediaImageViewerPage>(find.byType(MediaImageViewerPage))
|
|
.galleryItems!
|
|
.map((item) => item.url),
|
|
[photo],
|
|
);
|
|
Navigator.of(tester.element(find.byType(MediaImageViewerPage))).pop();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<Semantics>(spoken).properties.value,
|
|
'Attachment 2 of 3',
|
|
);
|
|
});
|
|
|
|
testWidgets(
|
|
'sent and received stacks browse whole cards and open the selected gallery photo',
|
|
(tester) async {
|
|
_setSurfaceSize(tester, const Size(375, 800));
|
|
tester.view.devicePixelRatio = 3;
|
|
tester.view.physicalSize = const Size(1125, 2400);
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
final semantics = tester.ensureSemantics();
|
|
const first = 'https://example.com/stack-one.png';
|
|
const second = 'https://example.com/stack-two.png';
|
|
const third = 'https://example.com/stack-three.png';
|
|
for (final own in [false, true]) {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(
|
|
key: ValueKey(own),
|
|
bubble: true,
|
|
ownMessage: own,
|
|
content: '\n\n',
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final stack = find.byKey(const ValueKey('message-media-stack'));
|
|
final spoken = find.byKey(
|
|
const ValueKey('message-media-stack-semantics'),
|
|
);
|
|
expect(
|
|
tester
|
|
.widget<Semantics>(
|
|
find.byKey(const ValueKey('message-media-stack-semantics')),
|
|
)
|
|
.properties
|
|
.value,
|
|
'Photo 1 of 3',
|
|
);
|
|
expect(
|
|
tester
|
|
.getSemantics(spoken)
|
|
.getSemanticsData()
|
|
.hasAction(SemanticsAction.increase),
|
|
isTrue,
|
|
);
|
|
Finder cardClip(int index) => find.descendant(
|
|
of: find.byKey(ValueKey('stack-card:$index')),
|
|
matching: find.byType(ClipRSuperellipse),
|
|
);
|
|
{
|
|
Matrix4 pose(int index) => tester
|
|
.widgetList<Transform>(
|
|
find.descendant(
|
|
of: find.byKey(ValueKey('stack-card:$index')),
|
|
matching: find.byType(Transform),
|
|
),
|
|
)
|
|
.last
|
|
.transform;
|
|
// Three-photo stacks retain each reference's distinct middle and
|
|
// back slots, rather than giving both rear cards one pose.
|
|
expect(pose(1).storage[12], closeTo(own ? 12 : 11, .001));
|
|
expect(pose(1).storage[13], closeTo(-5, .001));
|
|
expect(pose(1).storage[1], greaterThan(0));
|
|
expect(pose(2).storage[12], closeTo(own ? -10 : -11, .001));
|
|
expect(pose(2).storage[13], closeTo(4, .001));
|
|
expect(pose(2).storage[1], lessThan(0));
|
|
}
|
|
final resting = tester.getRect(cardClip(0));
|
|
final viewport = tester.getRect(stack);
|
|
final countLabel = find.byKey(
|
|
const ValueKey('message-media-stack-count'),
|
|
);
|
|
final countBefore = tester.getRect(countLabel);
|
|
expect(find.text('3 photos'), findsOneWidget);
|
|
expect(
|
|
find.byKey(const ValueKey('message-media-stack-counter')),
|
|
findsNothing,
|
|
);
|
|
expect(
|
|
own ? viewport.right - resting.right : resting.left - viewport.left,
|
|
closeTo(Grid.xs + Grid.half + Grid.twelve, 1),
|
|
);
|
|
// Sample the complete path, including either side of the layer swap.
|
|
final sweep = await tester.startGesture(tester.getCenter(stack));
|
|
await sweep.moveBy(const Offset(-20, 0));
|
|
for (var step = 1; step < 100; step++) {
|
|
await sweep.moveBy(const Offset(-1.4, 0));
|
|
await tester.pump();
|
|
for (final element
|
|
in find
|
|
.descendant(
|
|
of: stack,
|
|
matching: find.byType(ClipRSuperellipse),
|
|
)
|
|
.evaluate()) {
|
|
final box = element.renderObject! as RenderBox;
|
|
for (final corner in [
|
|
Offset.zero,
|
|
Offset(box.size.width, 0),
|
|
Offset(0, box.size.height),
|
|
Offset(box.size.width, box.size.height),
|
|
]) {
|
|
expect(
|
|
viewport.contains(box.localToGlobal(corner)),
|
|
isTrue,
|
|
reason:
|
|
'phase ${step / 100}: ${box.localToGlobal(corner)} outside $viewport',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
await sweep.moveBy(const Offset(138.6, 0));
|
|
await sweep.up();
|
|
await tester.pumpAndSettle();
|
|
final drag = await tester.startGesture(tester.getCenter(stack));
|
|
await drag.moveBy(const Offset(-20, 0));
|
|
await drag.moveBy(const Offset(-42, 0));
|
|
await tester.pump();
|
|
expect(
|
|
tester.getRect(countLabel),
|
|
countBefore,
|
|
reason: 'Photo count remains stationary during the swipe',
|
|
);
|
|
|
|
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
|
|
await drag.moveBy(const Offset(-28, 0));
|
|
await tester.pump();
|
|
final arriving = tester.getRect(cardClip(1));
|
|
// The solid cards meet at their edges when their paint order changes.
|
|
expect((arriving.center.dx - resting.center.dx).abs(), greaterThan(12));
|
|
final departing = tester.getRect(cardClip(0));
|
|
// The tuned rotation slightly narrows the projected outgoing card.
|
|
expect(departing.width, greaterThan(resting.width * .45));
|
|
expect(departing.width, lessThan(resting.width * .85));
|
|
expect(arriving.width, greaterThan(resting.width * .6));
|
|
expect(departing.intersect(arriving).width, lessThan(2));
|
|
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
|
|
expect(
|
|
find.descendant(of: stack, matching: find.byType(ClipPath)),
|
|
findsNothing,
|
|
);
|
|
// Every photo retains its own clip while the two front cards exchange.
|
|
final clips = find.descendant(
|
|
of: stack,
|
|
matching: find.byType(ClipRSuperellipse),
|
|
);
|
|
expect(clips, findsNWidgets(3));
|
|
for (final clip in tester.widgetList<ClipRSuperellipse>(clips)) {
|
|
expect(
|
|
clip.borderRadius,
|
|
BorderRadius.circular(Radii.container + Grid.xxs - Grid.half),
|
|
);
|
|
}
|
|
final stage = tester.getRect(stack);
|
|
for (final element in clips.evaluate()) {
|
|
final box = element.renderObject! as RenderBox;
|
|
for (final corner in [
|
|
Offset.zero,
|
|
Offset(box.size.width, 0),
|
|
Offset(0, box.size.height),
|
|
Offset(box.size.width, box.size.height),
|
|
]) {
|
|
expect(
|
|
stage.contains(box.localToGlobal(corner)),
|
|
isTrue,
|
|
reason: '${box.localToGlobal(corner)} outside $stage',
|
|
);
|
|
}
|
|
}
|
|
await drag.moveBy(const Offset(-28, 0));
|
|
await tester.pump();
|
|
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
|
|
await drag.up();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester
|
|
.widget<Semantics>(
|
|
find.byKey(const ValueKey('message-media-stack-semantics')),
|
|
)
|
|
.properties
|
|
.value,
|
|
'Photo 2 of 3',
|
|
);
|
|
// A canceled touch that never became a drag must not restore an old
|
|
// drag origin after a gallery changes the selected photo.
|
|
final canceledTouch = await tester.startGesture(
|
|
tester.getCenter(stack),
|
|
);
|
|
await canceledTouch.cancel();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<Semantics>(spoken).properties.value,
|
|
'Photo 2 of 3',
|
|
);
|
|
await tester.tap(stack);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
find.byKey(const ValueKey('message-media-image-viewer')),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
|
|
1,
|
|
);
|
|
final viewer = tester.widget<MediaImageViewerPage>(
|
|
find.byType(MediaImageViewerPage),
|
|
);
|
|
final preview = viewer.galleryItems![1];
|
|
final cardImage = tester.widget<MediaImage>(
|
|
find
|
|
.descendant(
|
|
skipOffstage: false,
|
|
of: find.byKey(
|
|
const ValueKey('stack-card:1'),
|
|
skipOffstage: false,
|
|
),
|
|
matching: find.byType(MediaImage, skipOffstage: false),
|
|
)
|
|
.first,
|
|
);
|
|
expect(cardImage.decodeWidth, lessThanOrEqualTo(210));
|
|
expect(preview.previewDecodeWidth, cardImage.decodeWidth);
|
|
final resized = preview.preloadProvider! as ResizeImage;
|
|
expect(resized.width, (cardImage.decodeWidth! * 3).ceil());
|
|
// Page in the full-screen gallery, close, then reopen the same photo.
|
|
await tester.drag(find.byType(PageView), const Offset(-350, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
|
|
2,
|
|
);
|
|
await tester.tap(
|
|
find.byKey(const ValueKey('message-media-image-viewer-close')),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<Semantics>(spoken).properties.value,
|
|
'Photo 3 of 3',
|
|
);
|
|
await tester.tap(stack);
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
|
|
2,
|
|
);
|
|
await tester.drag(find.byType(PageView), const Offset(350, 0));
|
|
await tester.pumpAndSettle();
|
|
// System back must preserve selection as well as the Close control.
|
|
await tester.binding.handlePopRoute();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.widget<Semantics>(spoken).properties.value,
|
|
'Photo 2 of 3',
|
|
);
|
|
await tester.drag(stack, const Offset(100, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester
|
|
.widget<Semantics>(
|
|
find.byKey(const ValueKey('message-media-stack-semantics')),
|
|
)
|
|
.properties
|
|
.value,
|
|
'Photo 1 of 3',
|
|
);
|
|
final reverse = await tester.startGesture(tester.getCenter(stack));
|
|
await reverse.moveBy(const Offset(-20, 0));
|
|
await reverse.moveBy(const Offset(-65, 0));
|
|
await tester.pump();
|
|
await reverse.moveBy(const Offset(65, 0));
|
|
await reverse.up();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester
|
|
.widget<Semantics>(
|
|
find.byKey(const ValueKey('message-media-stack-semantics')),
|
|
)
|
|
.properties
|
|
.value,
|
|
'Photo 1 of 3',
|
|
);
|
|
expect(
|
|
find.byKey(const ValueKey('message-media-image-viewer')),
|
|
findsNothing,
|
|
);
|
|
}
|
|
// Larger collections keep their rear cards quiet rather than multiplying
|
|
// the fan angle with every additional photo.
|
|
for (final own in [false, true]) {
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(
|
|
key: ValueKey(own),
|
|
ownMessage: own,
|
|
bubble: true,
|
|
content: List.filled(6, '').join('\n'),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final largeStack = find.byKey(const ValueKey('message-media-stack'));
|
|
for (final element
|
|
in find
|
|
.descendant(
|
|
of: largeStack,
|
|
matching: find.byType(ClipRSuperellipse),
|
|
)
|
|
.evaluate()) {
|
|
final box = element.renderObject! as RenderBox;
|
|
final edge =
|
|
box.localToGlobal(Offset(box.size.width, 0)) -
|
|
box.localToGlobal(Offset.zero);
|
|
expect((edge.dy / edge.dx).abs(), lessThan(own ? .12 : .2));
|
|
}
|
|
Finder rearClip(int index) => find.descendant(
|
|
of: find.byKey(ValueKey('stack-card:$index')),
|
|
matching: find.byType(ClipRSuperellipse),
|
|
);
|
|
final rearBefore = {
|
|
for (final i in [2, 3]) i: tester.getRect(rearClip(i)),
|
|
};
|
|
final rearCenters =
|
|
rearBefore.values.map((rect) => rect.center.dx).toList()..sort();
|
|
expect(
|
|
rearCenters.last - rearCenters.first,
|
|
lessThan(1),
|
|
reason: 'Large collections use compact backing tiers',
|
|
);
|
|
List<Key?> rearPaintOrder() => tester
|
|
.widget<Stack>(
|
|
find.byKey(const ValueKey('message-media-card-deck')),
|
|
)
|
|
.children
|
|
.map((child) => child.key)
|
|
.where((key) => [2, 3].any((i) => key == ValueKey('stack-card:$i')))
|
|
.toList();
|
|
final stableRearOrder = rearPaintOrder();
|
|
final deckDrag = await tester.startGesture(
|
|
tester.getCenter(largeStack),
|
|
);
|
|
await deckDrag.moveBy(const Offset(-20, 0));
|
|
void checkDeckBounds() {
|
|
final stage = tester.getRect(largeStack);
|
|
for (final element
|
|
in find
|
|
.descendant(
|
|
of: largeStack,
|
|
matching: find.byType(ClipRSuperellipse),
|
|
)
|
|
.evaluate()) {
|
|
final box = element.renderObject! as RenderBox;
|
|
for (final corner in [
|
|
Offset.zero,
|
|
Offset(box.size.width, 0),
|
|
Offset(0, box.size.height),
|
|
Offset(box.size.width, box.size.height),
|
|
]) {
|
|
expect(stage.contains(box.localToGlobal(corner)), isTrue);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (var step = 0; step < 50; step++) {
|
|
await deckDrag.moveBy(const Offset(-1.4, 0));
|
|
await tester.pump();
|
|
checkDeckBounds();
|
|
expect(
|
|
rearPaintOrder(),
|
|
stableRearOrder,
|
|
reason: 'Rear cards must not swap layers during a handoff',
|
|
);
|
|
}
|
|
expect(
|
|
find.descendant(
|
|
of: largeStack,
|
|
matching: find.byType(ClipRSuperellipse),
|
|
),
|
|
findsNWidgets(4),
|
|
);
|
|
for (final entry in rearBefore.entries) {
|
|
final moved = tester.getRect(rearClip(entry.key));
|
|
expect(
|
|
moved.width,
|
|
lessThan(entry.value.width * .9),
|
|
reason: 'Rear card ${entry.key} must turn with the deck',
|
|
);
|
|
expect(
|
|
(moved.center.dx - entry.value.center.dx).abs(),
|
|
greaterThan(8),
|
|
reason: 'Rear card ${entry.key} must travel with the deck',
|
|
);
|
|
}
|
|
for (var step = 0; step < 49; step++) {
|
|
await deckDrag.moveBy(const Offset(-1.4, 0));
|
|
await tester.pump();
|
|
checkDeckBounds();
|
|
expect(
|
|
rearPaintOrder(),
|
|
stableRearOrder,
|
|
reason: 'Rear cards must not swap layers during a handoff',
|
|
);
|
|
}
|
|
// The spring can render arbitrarily close to the next integer. The
|
|
// arriving photo must remain in front until and after that boundary.
|
|
await deckDrag.moveBy(const Offset(-1.393, 0));
|
|
await tester.pump();
|
|
final nearSettledDeck = tester.widget<Stack>(
|
|
find.byKey(const ValueKey('message-media-card-deck')),
|
|
);
|
|
final photoKeys = nearSettledDeck.children
|
|
.map((child) => child.key)
|
|
.where(
|
|
(key) =>
|
|
key is ValueKey<String> &&
|
|
key.value.startsWith('stack-card:'),
|
|
);
|
|
expect(
|
|
photoKeys.last,
|
|
const ValueKey('stack-card:1'),
|
|
reason: 'Do not flash the departing image just before settling',
|
|
);
|
|
double shadowStrength() => tester
|
|
.widgetList<DecoratedBox>(
|
|
find.descendant(
|
|
of: largeStack,
|
|
matching: find.byType(DecoratedBox),
|
|
),
|
|
)
|
|
.map((box) => box.decoration)
|
|
.whereType<ShapeDecoration>()
|
|
.expand((decoration) => decoration.shadows ?? <BoxShadow>[])
|
|
.fold(0.0, (sum, shadow) => sum + shadow.color.a);
|
|
final shadowBefore = shadowStrength();
|
|
expect(shadowBefore, closeTo(3 * 48 / 255, .001));
|
|
final outgoingBox = tester.renderObject<RenderBox>(rearClip(0));
|
|
final coveringBox = tester.renderObject<RenderBox>(rearClip(3));
|
|
for (final corner in [
|
|
Offset.zero,
|
|
Offset(outgoingBox.size.width, outgoingBox.size.height),
|
|
]) {
|
|
expect(
|
|
(outgoingBox.localToGlobal(corner) -
|
|
coveringBox.localToGlobal(corner))
|
|
.distance,
|
|
lessThan(.01),
|
|
reason: 'Outgoing image is fully covered before removal',
|
|
);
|
|
}
|
|
await deckDrag.up();
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
shadowStrength(),
|
|
closeTo(shadowBefore, .001),
|
|
reason: 'The rear shadow must survive the integer handoff',
|
|
);
|
|
expect(find.byKey(const ValueKey('stack-card:0')), findsNothing);
|
|
expect(find.byKey(const ValueKey('stack-card:3')), findsOneWidget);
|
|
// Start each next swipe while the previous spring is still running.
|
|
for (final dx in [-90.0, -90.0, 90.0, -90.0, 90.0, 90.0]) {
|
|
await tester.fling(largeStack, Offset(dx, 0), 2000);
|
|
for (var frame = 0; frame < 3; frame++) {
|
|
await tester.pump(const Duration(milliseconds: 16));
|
|
final value = tester
|
|
.widget<Semantics>(
|
|
find.byKey(const ValueKey('message-media-stack-semantics')),
|
|
)
|
|
.properties
|
|
.value!;
|
|
final selected = int.parse(value.split(' ')[1]) - 1;
|
|
final keys = tester
|
|
.widget<Stack>(
|
|
find.byKey(const ValueKey('message-media-card-deck')),
|
|
)
|
|
.children
|
|
.map((child) => child.key)
|
|
.where(
|
|
(key) =>
|
|
key is ValueKey<String> &&
|
|
key.value.startsWith('stack-card:'),
|
|
);
|
|
expect(keys.last, ValueKey('stack-card:$selected'));
|
|
expect(tester.takeException(), isNull);
|
|
}
|
|
}
|
|
await tester.pumpAndSettle();
|
|
}
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
const MessageContent(
|
|
bubble: true,
|
|
content: '\n',
|
|
),
|
|
disableAnimations: true,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
final spoken = find.byKey(
|
|
const ValueKey('message-media-stack-semantics'),
|
|
);
|
|
final node = tester.getSemantics(spoken);
|
|
node.owner!.performAction(node.id, SemanticsAction.decrease);
|
|
await tester.pump();
|
|
expect(
|
|
tester
|
|
.widget<Semantics>(
|
|
find.byKey(const ValueKey('message-media-stack-semantics')),
|
|
)
|
|
.properties
|
|
.value,
|
|
'Photo 2 of 2',
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
expect(tester.takeException(), isNull);
|
|
semantics.dispose();
|
|
},
|
|
);
|
|
testWidgets('stack tuner adjusts slots independently and resets', (
|
|
tester,
|
|
) async {
|
|
String? copiedSettings;
|
|
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
(call) async {
|
|
if (call.method == 'Clipboard.setData') {
|
|
copiedSettings = (call.arguments as Map)['text'] as String;
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
addTearDown(
|
|
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
|
SystemChannels.platform,
|
|
null,
|
|
),
|
|
);
|
|
_setSurfaceSize(tester, const Size(430, 932));
|
|
addTearDown(tester.view.resetPhysicalSize);
|
|
addTearDown(tester.view.resetDevicePixelRatio);
|
|
await tester.pumpWidget(
|
|
_testable(
|
|
MessageContent(
|
|
bubble: true,
|
|
ownMessage: true,
|
|
content: List.filled(
|
|
6,
|
|
'',
|
|
).join('\n'),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
await tester.tap(find.text('Tune stack'));
|
|
await tester.pumpAndSettle();
|
|
final sheet = find.byKey(const ValueKey('stack-tuning-sheet'));
|
|
Finder clip(int i) => find.descendant(
|
|
of: find.descendant(
|
|
of: sheet,
|
|
matching: find.byKey(ValueKey('stack-card:$i')),
|
|
),
|
|
matching: find.byType(ClipRSuperellipse),
|
|
);
|
|
final first = tester.getRect(clip(0));
|
|
final second = tester.getRect(clip(1));
|
|
final third = tester.getRect(clip(2));
|
|
await tester.tap(find.text('Card 2'));
|
|
await tester.pumpAndSettle();
|
|
final sliders = find.descendant(of: sheet, matching: find.byType(Slider));
|
|
await tester.drag(sliders.at(0), const Offset(45, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.getRect(clip(1)).center.dx,
|
|
greaterThan(second.center.dx + 1),
|
|
);
|
|
expect(tester.getRect(clip(0)), first);
|
|
expect(tester.getRect(clip(2)), third);
|
|
await tester.drag(sliders.at(1), const Offset(35, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(
|
|
tester.getRect(clip(1)).center.dy,
|
|
greaterThan(second.center.dy + 1),
|
|
);
|
|
await tester.drag(sliders.at(2), const Offset(35, 0));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.widget<Slider>(sliders.at(2)).value, greaterThan(0));
|
|
await tester.tap(find.text('Copy settings'));
|
|
await tester.pumpAndSettle();
|
|
expect(copiedSettings, isNotNull);
|
|
final exported = jsonDecode(copiedSettings!) as List;
|
|
expect(exported[0]['x'], 0);
|
|
expect(exported[1]['x'], greaterThan(0));
|
|
expect(exported[2]['x'], 0);
|
|
await tester.tap(find.text('Reset all'));
|
|
await tester.pumpAndSettle();
|
|
expect(tester.getRect(clip(1)), second);
|
|
expect(tester.widget<Slider>(sliders.at(0)).value, 0);
|
|
expect(tester.widget<Slider>(sliders.at(1)).value, 0);
|
|
expect(tester.widget<Slider>(sliders.at(2)).value, 0);
|
|
await tester.tap(find.text('Done'));
|
|
await tester.pumpAndSettle();
|
|
expect(sheet, findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
}, skip: !const bool.fromEnvironment('BUZZ_PHOTO_STACK_TUNING'));
|
|
}
|