11import 'dart:async' ;
2+ import 'dart:math' ;
23
34import 'package:checks/checks.dart' ;
45import 'package:clock/clock.dart' ;
@@ -10,12 +11,18 @@ import 'package:video_player_platform_interface/video_player_platform_interface.
1011import 'package:video_player/video_player.dart' ;
1112import 'package:zulip/api/model/model.dart' ;
1213import 'package:zulip/model/localizations.dart' ;
14+ import 'package:zulip/model/narrow.dart' ;
15+ import 'package:zulip/model/store.dart' ;
1316import 'package:zulip/widgets/app.dart' ;
1417import 'package:zulip/widgets/content.dart' ;
1518import 'package:zulip/widgets/lightbox.dart' ;
19+ import 'package:zulip/widgets/message_list.dart' ;
1620
21+ import '../api/fake_api.dart' ;
1722import '../example_data.dart' as eg;
1823import '../model/binding.dart' ;
24+ import '../model/content_test.dart' ;
25+ import '../model/test_store.dart' ;
1926import '../test_images.dart' ;
2027import 'dialog_checks.dart' ;
2128import 'test_app.dart' ;
@@ -197,6 +204,113 @@ class FakeVideoPlayerPlatform extends Fake
197204void main () {
198205 TestZulipBinding .ensureInitialized ();
199206
207+ group ('LightboxHero' , () {
208+ late PerAccountStore store;
209+ late FakeApiConnection connection;
210+
211+ final channel = eg.stream ();
212+ final message = eg.streamMessage (stream: channel,
213+ topic: 'test topic' , contentMarkdown: ContentExample .imageSingle.html);
214+
215+ // From ContentExample.imageSingle.
216+ final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp' ;
217+ final imageSrcUrl = Uri .parse (imageSrcUrlStr);
218+ final imageFinder = find.byWidgetPredicate (
219+ (widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl);
220+
221+ Future <void > setupMessageListPage (WidgetTester tester) async {
222+ addTearDown (testBinding.reset);
223+ final subscription = eg.subscription (channel);
224+ await testBinding.globalStore.add (eg.selfAccount, eg.initialSnapshot (
225+ streams: [channel], subscriptions: [subscription]));
226+ store = await testBinding.globalStore.perAccount (eg.selfAccount.id);
227+ connection = store.connection as FakeApiConnection ;
228+ await store.addUser (eg.selfUser);
229+
230+ connection.prepare (json:
231+ eg.newestGetMessagesResult (foundOldest: true , messages: [message]).toJson ());
232+ await tester.pumpWidget (TestZulipApp (accountId: eg.selfAccount.id,
233+ child: MessageListPage (initNarrow: const CombinedFeedNarrow ())));
234+ await tester.pumpAndSettle ();
235+ }
236+
237+ testWidgets ('Hero animation occurs smoothly when opening lightbox from message list' , (tester) async {
238+ double dist (Rect a, Rect b) =>
239+ sqrt (pow (a.top - b.top, 2 ) + pow (a.left - b.left, 2 ));
240+
241+ prepareBoringImageHttpClient ();
242+
243+ await setupMessageListPage (tester);
244+
245+ final initialImagePosition = tester.getRect (imageFinder);
246+ await tester.tap (imageFinder);
247+ await tester.pump ();
248+ // pump to start hero animation
249+ await tester.pump ();
250+
251+ const heroAnimationDuration = Duration (milliseconds: 300 );
252+ const steps = 150 ;
253+ final stepDuration = heroAnimationDuration ~ / steps;
254+ final animatedPositions = < Rect > [];
255+ for (int i = 1 ; i <= steps; i++ ) {
256+ await tester.pump (stepDuration);
257+ animatedPositions.add (tester.getRect (imageFinder));
258+ }
259+
260+ final totalDistance = dist (initialImagePosition, animatedPositions.last);
261+ Rect previousPosition = initialImagePosition;
262+ double maxStepDistance = 0.0 ;
263+ for (final position in animatedPositions) {
264+ final stepDistance = dist (previousPosition, position);
265+ maxStepDistance = max (maxStepDistance, stepDistance);
266+ check (position).not ((pos) => pos.equals (previousPosition));
267+
268+ previousPosition = position;
269+ }
270+ check (maxStepDistance).isLessThan (0.03 * totalDistance);
271+
272+ debugNetworkImageHttpClientProvider = null ;
273+ });
274+
275+ testWidgets ('no hero animation occurs between different message list pages for same image' , (tester) async {
276+ Rect getElementRect (Element element) =>
277+ tester.getRect (find.byElementPredicate ((e) => e == element));
278+
279+ prepareBoringImageHttpClient ();
280+
281+ await setupMessageListPage (tester);
282+
283+ final firstElement = tester.element (imageFinder);
284+ final firstImagePosition = getElementRect (firstElement);
285+
286+ connection.prepare (json:
287+ eg.newestGetMessagesResult (foundOldest: true , messages: [message]).toJson ());
288+ await tester.tap (find.descendant (
289+ of: find.byType (StreamMessageRecipientHeader ),
290+ matching: find.text ('test topic' )));
291+ await tester.pumpAndSettle ();
292+
293+ final secondElement = tester.element (imageFinder);
294+ final secondImagePosition = getElementRect (secondElement);
295+
296+ await tester.tap (find.byType (BackButton ));
297+ await tester.pump ();
298+
299+ const heroAnimationDuration = Duration (milliseconds: 300 );
300+ const steps = 150 ;
301+ final stepDuration = heroAnimationDuration ~ / steps;
302+ for (int i = 0 ; i < steps; i++ ) {
303+ await tester.pump (stepDuration);
304+ check (tester.elementList (imageFinder)).unorderedEquals ([
305+ firstElement, secondElement]);
306+ check (getElementRect (firstElement)).equals (firstImagePosition);
307+ check (getElementRect (secondElement)).equals (secondImagePosition);
308+ }
309+
310+ debugNetworkImageHttpClientProvider = null ;
311+ });
312+ });
313+
200314 group ('_ImageLightboxPage' , () {
201315 final src = Uri .parse ('https://chat.example/lightbox-image.png' );
202316
@@ -216,6 +330,7 @@ void main() {
216330 unawaited (navigator.push (getImageLightboxRoute (
217331 accountId: eg.selfAccount.id,
218332 message: message ?? eg.streamMessage (),
333+ messageImageContext: navigator.context,
219334 src: src,
220335 thumbnailUrl: thumbnailUrl,
221336 originalHeight: null ,
0 commit comments