From e6f8256b25923c537e7e2b95f77a11e8c9ee89f7 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Mon, 27 Jul 2026 08:29:46 -0700 Subject: [PATCH] fix(mobile): prevent timeline scroll to top on unrelated pages (#30281) --- .../widgets/timeline/timeline.widget.dart | 11 +- .../timeline/timeline_scroll_to_top_test.dart | 106 ++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 3f05edc2a4..20a4b4257e 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -184,7 +185,15 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi // Capture iOS status bar tap @override - void handleStatusBarTap() => _scrollToTop(); + void handleStatusBarTap() { + // Routes may be pushed non-opaquely on top of the timeline (such as the asset viewer), or the timeline + // may be in a background tab. In either case, `handleStatusBarTap()` still fires + // Make sure the timeline is the primary route before scrolling to the top + final routeData = context.findAncestorWidgetOfExactType()?.routeData; + if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) { + _scrollToTop(); + } + } void _onEvent(Event event) { switch (event) { diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart new file mode 100644 index 0000000000..28644d6702 --- /dev/null +++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +import '../../../fixtures/asset.stub.dart'; + +void main() { + testWidgets('status bar tap only scrolls the timeline when it is the visible page', (tester) async { + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(1206, 2622); + addTearDown(tester.view.reset); + + // Many assets for a tall viewport + final assets = List.generate(200, (i) => LocalAssetStub.image1.copyWith(id: 'a$i')); + final service = TimelineService(( + assetSource: (i, n) async => assets.sublist(i, math.min(i + n, assets.length)), + bucketSource: () => Stream.value([TimeBucket(date: DateTime(2025), assetCount: assets.length)]), + origin: TimelineOrigin.main, + )); + addTearDown(service.dispose); + + final router = RootStackRouter.build( + routes: [ + AutoRoute( + initial: true, + page: PageInfo( + 'Timeline', + builder: (_) => const Timeline( + withScrubber: false, + readOnly: true, + groupBy: GroupAssetsBy.none, + appBar: SliverToBoxAdapter(child: SizedBox.shrink()), + ), + ), + ), + AutoRoute( + path: '/overlay', + page: PageInfo('Overlay', builder: (_) => const Scaffold(body: SizedBox.shrink())), + type: RouteType.custom( + customRouteBuilder: (_, child, page) => + PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, __, ___) => child), + ), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(service), + appConfigProvider.overrideWithValue(const AppConfig()), + ], + child: MaterialApp.router(routerConfig: router.config()), + ), + ); + // Segment stream resolves + await tester.pump(); + // Asset buffer settles + await tester.pump(); + // Thumbnails will fail to load + tester.takeException(); + + final position = tester + .state(find.descendant(of: find.byType(Timeline), matching: find.byType(Scrollable)).first) + .position; + + // Closure mimicking the framework making a status bar tap call + Future tapStatusBar() => tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.statusBar.name, + SystemChannels.statusBar.codec.encodeMethodCall(const MethodCall('handleScrollToTop')), + null, + ); + + // Scroll down + await tester.drag(find.byType(Timeline), const Offset(0, -1500)); + await tester.pumpAndSettle(); + final initialScrolledPosition = position.pixels; + expect(initialScrolledPosition, greaterThan(200)); + + // Push a subroute. Taps on this status bar should be ignored + unawaited(router.pushPath('/overlay')); + await tester.pumpAndSettle(); + await tapStatusBar(); + await tester.pumpAndSettle(); + expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route'); + + // Once the timeline is visible, taps on the status bar should scroll to the top + await router.maybePop(); + await tester.pumpAndSettle(); + await tapStatusBar(); + await tester.pumpAndSettle(); + expect(position.pixels, 0, reason: 'scrolls the foreground timeline'); + }); +}