mirror of
https://github.com/immich-app/immich.git
synced 2026-07-30 23:50:36 -07:00
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:
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user