From 2f87ce154b1c1cb576d50cf0ec3a2dacefd2e1ad Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 7 Jul 2026 00:00:37 +0600 Subject: [PATCH] fix(mobile): update timeline args on layout constraint changes --- .../widgets/timeline/timeline.state.dart | 11 +- .../widgets/timeline/timeline.widget.dart | 25 ++-- .../infrastructure/timeline.provider.dart | 8 +- .../widgets/timeline/timeline_args_test.dart | 119 ++++++++++++++++++ 4 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 mobile/test/presentation/widgets/timeline/timeline_args_test.dart diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 8dd87f9868..b39c431b43 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -86,13 +86,14 @@ class TimelineStateNotifier extends Notifier { // This provider watches the buckets from the timeline service & args and serves the segments. // It should be used only after the timeline service and timeline args provider is overridden final timelineSegmentProvider = StreamProvider.autoDispose>((ref) async* { - final args = ref.watch(timelineArgsProvider); - final columnCount = args.columnCount; - final spacing = args.spacing; - final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); + // maxHeight is left out on purpose, a height-only change must not restart the bucket stream + final (maxWidth, columnCount, spacing, groupByArg) = ref.watch( + timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)), + ); + final availableTileWidth = maxWidth - (spacing * (columnCount - 1)); final tileExtent = math.max(0, availableTileWidth) / columnCount; - final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); + final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); final timelineService = ref.watch(timelineServiceProvider); yield* timelineService.watchBuckets().map((buckets) { diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 8acaebf40e..817df7f071 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,7 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; -class Timeline extends StatelessWidget { +class Timeline extends ConsumerWidget { const Timeline({ super.key, this.topSliverWidget, @@ -62,15 +62,18 @@ class Timeline extends StatelessWidget { final Widget? loadingWidget; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)); return LayoutBuilder( builder: (_, constraints) => ProviderScope( overrides: [ - timelineArgsProvider.overrideWith( - (ref) => TimelineArgs( + // overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds, + // a function override would stay locked to the first frame's constraints for the whole session + timelineArgsProvider.overrideWithValue( + TimelineArgs( maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight, - columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), + columnCount: columnCount, showStorageIndicator: showStorageIndicator, withStack: withStack, groupBy: groupBy, @@ -168,13 +171,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { void didUpdateWidget(covariant _SliverTimeline oldWidget) { super.didUpdateWidget(oldWidget); if (widget.maxWidth != oldWidget.maxWidth) { - final asyncSegments = ref.read(timelineSegmentProvider); - asyncSegments.whenData((segments) { - final index = _getCurrentAssetIndex(segments); - // Refresh to wait for new segments to be generated with the updated width before restoring the scroll position - final _ = ref.refresh(timelineArgsProvider); - _restoreAssetIndex = index; - }); + // The updated args already regenerate the segments, only remember the scroll position to restore it afterwards + final segments = ref.read(timelineSegmentProvider).valueOrNull; + if (segments != null && _scrollController.hasClients) { + _restoreAssetIndex = _getCurrentAssetIndex(segments); + } } } diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index b22c693033..a67b8dd822 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; @@ -39,5 +40,10 @@ final timelineUsersProvider = StreamProvider>((ref) { return Stream.value([]); } - return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId); + // Drift re-emits a fresh but content-identical list on unrelated table updates, + // which would dispose and rebuild the timeline service mid-load + return ref + .watch(timelineRepositoryProvider) + .watchTimelineUserIds(currentUserId) + .distinct(const ListEquality().equals); }); diff --git a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart new file mode 100644 index 0000000000..614fcd749a --- /dev/null +++ b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.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.state.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'; + +// A first fetch that never delivers - the state a suspended or storm-starved +// bucket watch is stuck in when the timeline mounts on a zero-sized first frame +class _FrozenBucketService implements TimelineService { + final _ctrl = StreamController>.broadcast(); + + @override + Stream> Function() get watchBuckets => + () => _ctrl.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _EmptyBucketService implements TimelineService { + @override + Stream> Function() get watchBuckets => + () => Stream.value(const []); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', ( + tester, + ) async { + tester.view.physicalSize = Size.zero; + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + TimelineArgs? probed; + final probe = Consumer( + builder: (_, ref, __) { + probed = ref.watch(timelineArgsProvider); + return const SizedBox.shrink(); + }, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(_FrozenBucketService()), + appConfigProvider.overrideWithValue(const AppConfig()), + ], + child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)), + ), + ); + await tester.pump(); + + expect(probed, isNotNull); + expect(probed!.maxWidth, 0.0); + + tester.view.physicalSize = const Size(1206, 2622); + await tester.pump(); + await tester.pump(); + + expect( + probed!.maxWidth, + 402.0, + reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session', + ); + }); + + testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async { + tester.view.physicalSize = Size.zero; + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + TimelineArgs? probed; + final probe = SliverToBoxAdapter( + child: Consumer( + builder: (_, ref, __) { + probed = ref.watch(timelineArgsProvider); + return const SizedBox.shrink(); + }, + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(_EmptyBucketService()), + appConfigProvider.overrideWithValue(const AppConfig()), + ], + child: MaterialApp( + home: Timeline( + withScrubber: false, + readOnly: true, + appBar: const SliverToBoxAdapter(child: SizedBox.shrink()), + topSliverWidget: probe, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + tester.view.physicalSize = const Size(1206, 2622); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(probed, isNotNull); + expect(probed!.maxWidth, 402.0); + }); +}