fix(mobile): update timeline args on layout constraint changes (#29653)

* fix(mobile): update timeline args on layout constraint changes

* chore(mobile): temporary blank timeline diagnostics

* chore(mobile): error logs when the timeline goes blank

* chore(mobile): remove blank timeline debug logging

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Santo Shakil
2026-07-08 14:13:32 -05:00
committed by GitHub
co-authored by Alex
parent 427321a102
commit 2ad554fc61
4 changed files with 240 additions and 40 deletions
@@ -86,13 +86,14 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
// This provider watches the buckets from the timeline service & args and serves the segments. // 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 // It should be used only after the timeline service and timeline args provider is overridden
final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* { final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref) async* {
final args = ref.watch(timelineArgsProvider); // maxHeight is left out on purpose, a height-only change must not restart the bucket stream
final columnCount = args.columnCount; final (maxWidth, columnCount, spacing, groupByArg) = ref.watch(
final spacing = args.spacing; timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)),
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); );
final availableTileWidth = maxWidth - (spacing * (columnCount - 1));
final tileExtent = math.max(0, availableTileWidth) / columnCount; 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); final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) { yield* timelineService.watchBuckets().map((buckets) {
@@ -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/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_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({ const Timeline({
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
@@ -62,35 +62,40 @@ class Timeline extends StatelessWidget {
final Widget? loadingWidget; final Widget? loadingWidget;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
return LayoutBuilder( return LayoutBuilder(
builder: (_, constraints) => ProviderScope( builder: (_, constraints) {
overrides: [ return ProviderScope(
timelineArgsProvider.overrideWith( overrides: [
(ref) => TimelineArgs( // overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds,
maxWidth: constraints.maxWidth, // a function override would stay locked to the first frame's constraints for the whole session
maxHeight: constraints.maxHeight, timelineArgsProvider.overrideWithValue(
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), TimelineArgs(
showStorageIndicator: showStorageIndicator, maxWidth: constraints.maxWidth,
withStack: withStack, maxHeight: constraints.maxHeight,
groupBy: groupBy, columnCount: columnCount,
showStorageIndicator: showStorageIndicator,
withStack: withStack,
groupBy: groupBy,
),
), ),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
],
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
), ),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), );
], },
child: _SliverTimeline(
topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget,
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
persistentBottomBar: persistentBottomBar,
snapToMonth: snapToMonth,
maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget,
),
),
); );
} }
} }
@@ -169,13 +174,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
void didUpdateWidget(covariant _SliverTimeline oldWidget) { void didUpdateWidget(covariant _SliverTimeline oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.maxWidth != oldWidget.maxWidth) { if (widget.maxWidth != oldWidget.maxWidth) {
final asyncSegments = ref.read(timelineSegmentProvider); // The updated args already regenerate the segments, only remember the scroll position to restore it afterwards
asyncSegments.whenData((segments) { final segments = ref.read(timelineSegmentProvider).valueOrNull;
final index = _getCurrentAssetIndex(segments); if (segments != null && _scrollController.hasClients) {
// Refresh to wait for new segments to be generated with the updated width before restoring the scroll position _restoreAssetIndex = _getCurrentAssetIndex(segments);
final _ = ref.refresh(timelineArgsProvider); }
_restoreAssetIndex = index;
});
} }
} }
@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
@@ -39,5 +40,10 @@ final timelineUsersProvider = StreamProvider<List<String>>((ref) {
return Stream.value([]); 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<String>().equals);
}); });
@@ -0,0 +1,190 @@
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<List<Bucket>>.broadcast();
@override
Stream<List<Bucket>> Function() get watchBuckets =>
() => _ctrl.stream;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _EmptyBucketService implements TimelineService {
@override
Stream<List<Bucket>> Function() get watchBuckets =>
() => Stream.value(const []);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
// Counts how many times the bucket stream is subscribed. Each subscription is a
// fresh photo query, so the count is our proxy for "did the relayout re-run the
// segment query for an input it does not use".
class _CountingBucketService implements TimelineService {
int watchCount = 0;
final _ctrl = StreamController<List<Bucket>>.broadcast();
@override
Stream<List<Bucket>> Function() get watchBuckets => () {
watchCount++;
return _ctrl.stream;
};
@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);
});
testWidgets('a height-only relayout (multiselect app bar toggle) keeps the timeline, a width change refreshes it', (
tester,
) async {
final service = _CountingBucketService();
tester.view.devicePixelRatio = 3.0;
tester.view.physicalSize = const Size(1206, 2622);
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(service),
appConfigProvider.overrideWithValue(const AppConfig()),
],
child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)),
),
);
await tester.pump();
final initialSubscriptions = service.watchCount;
final initialWidth = probed!.maxWidth;
final initialHeight = probed!.maxHeight;
expect(initialSubscriptions, greaterThan(0));
// toggling multiselect changes the app bar, so only the available height moves
tester.view.physicalSize = const Size(1206, 2000);
await tester.pump();
await tester.pump();
expect(probed!.maxHeight, isNot(initialHeight), reason: 'the height should have actually changed');
expect(probed!.maxWidth, initialWidth);
expect(
service.watchCount,
initialSubscriptions,
reason: 'a height-only change must not re-run the bucket query for an input the segments do not use',
);
// a real width change (rotation, fold, split screen) should refresh the tiles
tester.view.physicalSize = const Size(1000, 2000);
await tester.pump();
await tester.pump();
expect(probed!.maxWidth, lessThan(initialWidth));
expect(service.watchCount, greaterThan(initialSubscriptions));
});
}