mirror of
https://github.com/immich-app/immich.git
synced 2026-07-06 12:36:55 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc3a8c7ec | |||
| 2f87ce154b |
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
|
||||
|
||||
@@ -108,6 +109,9 @@ class TimelineService {
|
||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||
_mutex.run(() async {
|
||||
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||
Logger(
|
||||
'TimelineProbe',
|
||||
).info('[${origin.name}] bucket emission: ${buckets.length} buckets / $totalAssets assets');
|
||||
|
||||
if (totalAssets == 0) {
|
||||
_bufferOffset = 0;
|
||||
|
||||
@@ -58,6 +58,12 @@ void main() async {
|
||||
await workerManagerPatch.init(dynamicSpawning: true, isolatesCount: max(Platform.numberOfProcessors - 1, 5));
|
||||
await migrateDatabaseIfNeeded(drift);
|
||||
|
||||
Logger('LaunchProbe').info(
|
||||
'launch: prewarm=${Platform.environment['ActivePrewarm'] ?? '0'}, '
|
||||
'view=${WidgetsBinding.instance.platformDispatcher.implicitView?.physicalSize}, '
|
||||
'lifecycle=${WidgetsBinding.instance.lifecycleState}',
|
||||
);
|
||||
|
||||
runApp(ProviderScope(overrides: [driftProvider.overrideWith(driftOverride(drift))], child: const MainWidget()));
|
||||
} catch (error, stack) {
|
||||
runApp(BootstrapErrorWidget(error: error.toString(), stack: stack.toString()));
|
||||
@@ -123,28 +129,26 @@ class ImmichApp extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserver {
|
||||
final _lifecycleLog = Logger('AppLifeCycle');
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_lifecycleLog.info(state.name);
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
dPrint(() => "[APP STATE] resumed");
|
||||
ref.read(appStateProvider.notifier).handleAppResume();
|
||||
unawaited(ref.read(viewIntentHandlerProvider).onAppResumed());
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
dPrint(() => "[APP STATE] inactive");
|
||||
ref.read(appStateProvider.notifier).handleAppInactivity();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
dPrint(() => "[APP STATE] paused");
|
||||
ref.read(appStateProvider.notifier).handleAppPause();
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
dPrint(() => "[APP STATE] detached");
|
||||
ref.read(appStateProvider.notifier).handleAppDetached();
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
dPrint(() => "[APP STATE] hidden");
|
||||
ref.read(appStateProvider.notifier).handleAppHidden();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TimelineArgs {
|
||||
final double maxWidth;
|
||||
@@ -86,13 +87,15 @@ class TimelineStateNotifier extends Notifier<TimelineState> {
|
||||
// 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<List<Segment>>((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;
|
||||
Logger('TimelineProbe').info('segments: maxWidth=$maxWidth tileExtent=$tileExtent columns=$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) {
|
||||
|
||||
@@ -28,8 +28,9 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
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';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
class Timeline extends ConsumerWidget {
|
||||
const Timeline({
|
||||
super.key,
|
||||
this.topSliverWidget,
|
||||
@@ -62,35 +63,41 @@ 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(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: constraints.maxHeight,
|
||||
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
withStack: withStack,
|
||||
groupBy: groupBy,
|
||||
builder: (_, constraints) {
|
||||
Logger('TimelineProbe').info('layout pass ${constraints.maxWidth}x${constraints.maxHeight}');
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
// 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: 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -153,6 +160,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Logger('TimelineProbe').info('timeline mounted maxWidth=${widget.maxWidth}');
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
@@ -168,13 +176,12 @@ 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;
|
||||
});
|
||||
Logger('TimelineProbe').info('timeline maxWidth ${oldWidget.maxWidth} -> ${widget.maxWidth}');
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
@@ -17,8 +19,12 @@ final timelineArgsProvider = Provider.autoDispose<TimelineArgs>(
|
||||
final timelineServiceProvider = Provider<TimelineService>(
|
||||
(ref) {
|
||||
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
|
||||
Logger('TimelineProbe').info('main timeline service built with ${timelineUsers.length} users');
|
||||
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
ref.onDispose(() {
|
||||
Logger('TimelineProbe').info('main timeline service disposed');
|
||||
timelineService.dispose();
|
||||
});
|
||||
return timelineService;
|
||||
},
|
||||
// Empty dependencies to inform the framework that this provider
|
||||
@@ -39,5 +45,14 @@ final timelineUsersProvider = StreamProvider<List<String>>((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<String>().equals)
|
||||
.map((users) {
|
||||
Logger('TimelineProbe').info('timeline users emission: ${users.length}');
|
||||
return users;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user