Compare commits

...

9 Commits

Author SHA1 Message Date
shenlong-tanwen f4136f6487 feat: add pull to refresh 2026-07-03 03:23:32 +05:30
shenlong-tanwen 2217d2e679 add timeline state logs 2026-07-03 02:50:56 +05:30
Alex 18fec31c1e Merge branch 'main' of github.com:immich-app/immich into debug/blank-timeline-fix 2026-06-29 09:09:32 -05:00
shenlong-tanwen 57eee0148c log isolate hashcode 2026-06-27 00:50:17 +05:30
shenlong-tanwen 880f8e2e4c chore: cancel native operations - lifecycle change 2026-06-27 00:23:11 +05:30
shenlong-tanwen 3f67c400d2 chore: pump drift 2026-06-27 00:23:11 +05:30
shenlong-tanwen 5f5ce12041 debug: full local sync 2026-06-27 00:23:11 +05:30
shenlong-tanwen 1c3266e8f2 debug: more logs
# Conflicts:
#	mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
#	mobile/lib/presentation/widgets/timeline/timeline.widget.dart
2026-06-27 00:23:11 +05:30
shenlong-tanwen 912484dcdd debug: blank timeline rendering 2026-06-27 00:23:11 +05:30
12 changed files with 228 additions and 58 deletions
@@ -138,7 +138,9 @@ class LocalSyncService {
final Stopwatch stopwatch = Stopwatch()..start(); final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await _nativeSyncApi.getAlbums(); final deviceAlbums = await _nativeSyncApi.getAlbums();
final getAlbumsTime = stopwatch.elapsedMilliseconds;
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id}); final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
final getAllTime = stopwatch.elapsedMilliseconds;
await diffSortedLists( await diffSortedLists(
dbAlbums, dbAlbums,
@@ -148,10 +150,15 @@ class LocalSyncService {
onlyFirst: removeAlbum, onlyFirst: removeAlbum,
onlySecond: addAlbum, onlySecond: addAlbum,
); );
final diffTime = stopwatch.elapsedMilliseconds;
await _nativeSyncApi.checkpointSync(); await _nativeSyncApi.checkpointSync();
stopwatch.stop(); stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); _log.info(
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
);
} on PlatformException catch (e, s) { } on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) { if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled"); _log.warning("Full device sync cancelled");
+4 -2
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:isolate';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
@@ -66,15 +67,16 @@ class LogService {
} }
void _handleLogRecord(LogRecord r) { void _handleLogRecord(LogRecord r) {
final int isolateHash = Isolate.current.hashCode;
dPrint( dPrint(
() => () =>
'[${r.level.name}] [${r.time}] [${r.loggerName}] ${r.message}' '[${r.level.name}] [${r.time}] [${r.loggerName}] [$isolateHash] ${r.message}'
'${r.error == null ? '' : '\nError: ${r.error}'}' '${r.error == null ? '' : '\nError: ${r.error}'}'
'${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}', '${r.stackTrace == null ? '' : '\nStack: ${r.stackTrace}'}',
); );
final record = LogMessage( final record = LogMessage(
message: r.message, message: '[$isolateHash] ${r.message}',
level: r.level.toLogLevel(), level: r.level.toLogLevel(),
createdAt: r.time, createdAt: r.time,
logger: r.loggerName, logger: r.loggerName,
@@ -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/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:logging/logging.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count); typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -90,6 +91,7 @@ class TimelineFactory {
} }
class TimelineService { class TimelineService {
static final Logger _log = Logger('TimelineService');
final TimelineAssetSource _assetSource; final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource; final TimelineBucketSource _bucketSource;
final TimelineOrigin origin; final TimelineOrigin origin;
@@ -105,34 +107,49 @@ class TimelineService {
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin); : this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) { TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen((buckets) { _bucketSubscription = _bucketSource().listen(
_mutex.run(() async { (buckets) {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount); _mutex.run(() async {
try {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
if (totalAssets == 0) { _log.info(
_bufferOffset = 0; '[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
_buffer = []; '(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
} else { );
final int offset;
final int count; if (totalAssets == 0) {
// When the buffer is empty or the old bufferOffset is greater than the new total assets, _bufferOffset = 0;
// we need to reset the buffer and load the first batch of assets. _buffer = [];
if (_bufferOffset >= totalAssets || _buffer.isEmpty) { } else {
offset = 0; final int offset;
count = kTimelineAssetLoadBatchSize; final int count;
} else { // When the buffer is empty or the old bufferOffset is greater than the new total assets,
offset = _bufferOffset; // we need to reset the buffer and load the first batch of assets.
count = math.min(_buffer.length, totalAssets - _bufferOffset); if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
}
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
} catch (error, stack) {
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
rethrow;
} }
_buffer = await _assetSource(offset, count); });
_bufferOffset = offset; },
} onError: (Object error, StackTrace stack) {
_log.severe('[$origin] bucket stream errored', error, stack);
// change the state's total assets count only after the buffer is reloaded },
_totalAssets = totalAssets; );
EventStream.shared.emit(const TimelineReloadEvent());
});
});
} }
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource; Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
@@ -164,6 +181,13 @@ class TimelineService {
_buffer = await _assetSource(start, len); _buffer = await _assetSource(start, len);
_bufferOffset = start; _bufferOffset = start;
if (!hasRange(index, count)) {
_log.warning(
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
);
}
return getAssets(index, count); return getAssets(index, count);
} }
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart'; import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:logging/logging.dart';
@RoutePage() @RoutePage()
class MainTimelinePage extends ConsumerWidget { class MainTimelinePage extends ConsumerWidget {
@@ -16,6 +17,9 @@ class MainTimelinePage extends ConsumerWidget {
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()), topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0, topSliverWidgetHeight: hasMemories ? 200 : 0,
showStorageIndicator: true, showStorageIndicator: true,
onRefresh: () {
Logger('MainTimelinePage').info('Pull-to-refresh triggered');
},
); );
} }
} }
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
class FixedSegment extends Segment { class FixedSegment extends Segment {
final double tileHeight; final double tileHeight;
@@ -41,8 +42,7 @@ class FixedSegment extends Segment {
required super.headerExtent, required super.headerExtent,
required super.spacing, required super.spacing,
required super.header, required super.header,
}) : assert(tileHeight != 0), }) : mainAxisExtend = tileHeight + spacing;
mainAxisExtend = tileHeight + spacing;
@override @override
double indexToLayoutOffset(int index) { double indexToLayoutOffset(int index) {
@@ -90,6 +90,7 @@ class FixedSegment extends Segment {
} }
class _FixedSegmentRow extends ConsumerWidget { class _FixedSegmentRow extends ConsumerWidget {
static final Logger _log = Logger('TimelineRow');
final int assetIndex; final int assetIndex;
final int assetCount; final int assetCount;
final double tileHeight; final double tileHeight;
@@ -109,8 +110,20 @@ class _FixedSegmentRow extends ConsumerWidget {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3); final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
final inRange = timelineService.hasRange(assetIndex, assetCount);
if (timelineService.hasRange(assetIndex, assetCount)) { if (assetIndex == 0) {
_log.info(
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
'branch=${inRange
? "assets"
: isScrubbing
? "placeholder(scrubbing)"
: "future(load)"}',
);
}
if (inRange) {
return _buildAssetRow( return _buildAssetRow(
context, context,
timelineService.getAssets(assetIndex, assetCount), timelineService.getAssets(assetIndex, assetCount),
@@ -129,6 +142,13 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context); return _buildPlaceholder(context);
} }
if (snapshot.hasError) {
_log.warning(
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
snapshot.error,
snapshot.stackTrace,
);
}
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout); return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
}, },
); );
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection; import 'package:intl/intl.dart' hide TextDirection;
import 'package:logging/logging.dart';
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged /// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView. /// for quick navigation of the BoxScrollView.
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
} }
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin { class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
static final Logger _log = Logger('Scrubber');
String? _lastLabel; String? _lastLabel;
double _thumbTopOffset = 0.0; double _thumbTopOffset = 0.0;
bool _isDragging = false; bool _isDragging = false;
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_log.info('Scrubber initState');
_isDragging = false; _isDragging = false;
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight); _segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration); _thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
void didUpdateWidget(covariant Scrubber oldWidget) { void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) { final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
if (oldEnd != newEnd) {
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight); _segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount(); _monthCount = getMonthCount();
} }
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override @override
void dispose() { void dispose() {
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
_log.warning(
'Scrubber dispose mid-scrub '
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
);
} else {
_log.info('Scrubber dispose');
}
_thumbAnimationController.dispose(); _thumbAnimationController.dispose();
_labelAnimationController.dispose(); _labelAnimationController.dispose();
_fadeOutTimer?.cancel(); _fadeOutTimer?.cancel();
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
} }
void _onDragStart(DragStartDetails _) { void _onDragStart(DragStartDetails _) {
_log.info('scrub dragStart');
setState(() { setState(() {
_isDragging = true; _isDragging = true;
_labelAnimationController.forward(); _labelAnimationController.forward();
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
} }
if (_scrubberHeight <= 0) { if (_scrubberHeight <= 0) {
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
return; return;
} }
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
if (maxScrollExtent <= 0) {
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
}
if (_thumbAnimationController.status != AnimationStatus.forward) { if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward(); _thumbAnimationController.forward();
} }
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
} }
void _onDragEnd(DragEndDetails _) { void _onDragEnd(DragEndDetails _) {
_log.info('scrub dragEnd -> setScrubbing(false)');
_labelAnimationController.reverse(); _labelAnimationController.reverse();
setState(() { setState(() {
_isDragging = false; _isDragging = false;
@@ -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/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class TimelineArgs { class TimelineArgs {
final double maxWidth; final double maxWidth;
@@ -71,14 +72,27 @@ class TimelineState {
} }
class TimelineStateNotifier extends Notifier<TimelineState> { class TimelineStateNotifier extends Notifier<TimelineState> {
static final Logger _log = Logger('TimelineState');
void setScrubbing(bool isScrubbing) { void setScrubbing(bool isScrubbing) {
if (state.isScrubbing != isScrubbing) {
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
}
state = state.copyWith(isScrubbing: isScrubbing); state = state.copyWith(isScrubbing: isScrubbing);
} }
void setScrolling(bool isScrolling) { void setScrolling(bool isScrolling) {
if (state.isScrolling != isScrolling) {
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
}
state = state.copyWith(isScrolling: isScrolling); state = state.copyWith(isScrolling: isScrolling);
} }
static String _callSite() {
final frames = StackTrace.current.toString().split('\n');
return frames.length > 2 ? frames[2].trim() : 'unknown';
}
@override @override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false); TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
} }
@@ -86,16 +100,29 @@ 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 log = Logger('TimelineSegmentProvider');
final args = ref.watch(timelineArgsProvider); final args = ref.watch(timelineArgsProvider);
final columnCount = args.columnCount; final columnCount = args.columnCount;
final spacing = args.spacing; final spacing = args.spacing;
final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1));
log.info(
'timelineSegmentProvider: maxWidth=${args.maxWidth}, spacing=$spacing, availableTileWidth=$availableTileWidth, columnCount=$columnCount',
);
final tileExtent = math.max(0, availableTileWidth) / columnCount; final tileExtent = math.max(0, availableTileWidth) / columnCount;
log.info(
'timelineSegmentProvider: tileExtent=$tileExtent (availableTileWidth=$availableTileWidth / columnCount=$columnCount)',
);
final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); final groupBy = args.groupBy ?? 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) {
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
Logger('TimelineService').info(
'[${timelineService.origin}] segment layout: '
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
);
return FixedSegmentBuilder( return FixedSegmentBuilder(
buckets: buckets, buckets: buckets,
tileHeight: tileExtent, tileHeight: tileExtent,
@@ -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/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';
import 'package:logging/logging.dart';
class Timeline extends StatelessWidget { class Timeline extends StatefulWidget {
const Timeline({ const Timeline({
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
@@ -45,6 +46,7 @@ class Timeline extends StatelessWidget {
this.readOnly = false, this.readOnly = false,
this.persistentBottomBar = false, this.persistentBottomBar = false,
this.loadingWidget, this.loadingWidget,
this.onRefresh,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -60,35 +62,56 @@ class Timeline extends StatelessWidget {
final bool readOnly; final bool readOnly;
final bool persistentBottomBar; final bool persistentBottomBar;
final Widget? loadingWidget; final Widget? loadingWidget;
final VoidCallback? onRefresh;
@override
State<Timeline> createState() => _TimelineState();
}
class _TimelineState extends State<Timeline> {
bool _rebuildTrigger = false;
Future<void> _handleRefresh() async {
if (widget.onRefresh == null) {
return;
}
widget.onRefresh?.call();
if (mounted) {
setState(() => _rebuildTrigger = !_rebuildTrigger);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (_, constraints) => ProviderScope( builder: (_, constraints) => ProviderScope(
key: ValueKey(_rebuildTrigger),
overrides: [ overrides: [
timelineArgsProvider.overrideWith( timelineArgsProvider.overrideWith(
(ref) => TimelineArgs( (ref) => TimelineArgs(
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight, maxHeight: constraints.maxHeight,
columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)),
showStorageIndicator: showStorageIndicator, showStorageIndicator: widget.showStorageIndicator,
withStack: withStack, withStack: widget.withStack,
groupBy: groupBy, groupBy: widget.groupBy,
), ),
), ),
if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), if (widget.readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()),
], ],
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: widget.topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: widget.topSliverWidgetHeight,
bottomSliverWidget: bottomSliverWidget, bottomSliverWidget: widget.bottomSliverWidget,
appBar: appBar, appBar: widget.appBar,
bottomSheet: bottomSheet, bottomSheet: widget.bottomSheet,
withScrubber: withScrubber, withScrubber: widget.withScrubber,
persistentBottomBar: persistentBottomBar, persistentBottomBar: widget.persistentBottomBar,
snapToMonth: snapToMonth, snapToMonth: widget.snapToMonth,
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
loadingWidget: loadingWidget, loadingWidget: widget.loadingWidget,
onRefresh: widget.onRefresh == null ? null : _handleRefresh,
), ),
), ),
); );
@@ -118,6 +141,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.snapToMonth = true, this.snapToMonth = true,
this.maxWidth, this.maxWidth,
this.loadingWidget, this.loadingWidget,
this.onRefresh,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -130,12 +154,14 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final bool snapToMonth; final bool snapToMonth;
final double? maxWidth; final double? maxWidth;
final Widget? loadingWidget; final Widget? loadingWidget;
final Future<void> Function()? onRefresh;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
} }
class _SliverTimelineState extends ConsumerState<_SliverTimeline> { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
static final Logger _log = Logger('Timeline');
late final ScrollController _scrollController; late final ScrollController _scrollController;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@@ -153,6 +179,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_log.info('SliverTimeline initState');
_scrollController = ScrollController(onAttach: _restoreAssetPosition); _scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent); _eventSubscription = EventStream.shared.listen(_onEvent);
@@ -179,6 +206,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} }
void _onEvent(Event event) { void _onEvent(Event event) {
_log.info('event ${event.runtimeType}');
switch (event) { switch (event) {
case ScrollToTopEvent(): case ScrollToTopEvent():
{ {
@@ -186,7 +214,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineState.setScrubbing(true); timelineState.setScrubbing(true);
_scrollController _scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut) .animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false)); .whenComplete(() {
_log.info('ScrollToTop animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
} }
case ScrollToDateEvent scrollToDateEvent: case ScrollToDateEvent scrollToDateEvent:
@@ -246,6 +277,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override @override
void dispose() { void dispose() {
_log.info('SliverTimeline dispose');
_scrollController.dispose(); _scrollController.dispose();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
super.dispose(); super.dispose();
@@ -286,8 +318,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut, curve: Curves.easeInOut,
) )
.whenComplete(() => timelineState.setScrubbing(false)); .whenComplete(() {
_log.info('ScrollToDate animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
} else { } else {
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
timelineState.setScrubbing(false); timelineState.setScrubbing(false);
} }
}); });
@@ -425,7 +461,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
], ],
); );
final Widget timeline; Widget timeline;
if (widget.withScrubber) { if (widget.withScrubber) {
timeline = Scrubber( timeline = Scrubber(
snapToMonth: widget.snapToMonth, snapToMonth: widget.snapToMonth,
@@ -440,6 +476,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
} else { } else {
timeline = grid; timeline = grid;
} }
if (widget.onRefresh != null) {
timeline = RefreshIndicator(onRefresh: widget.onRefresh!, child: timeline);
}
return RawGestureDetector( return RawGestureDetector(
gestures: { gestures: {
@@ -101,8 +101,20 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
} }
} }
Future<void> _cancelSync() async {
final backgroundManager = _ref.read(backgroundSyncProvider);
final nativeSync = _ref.read(nativeSyncApiProvider);
await Future.wait([
nativeSync.cancelSync(),
nativeSync.cancelHashing(),
backgroundManager.cancel(),
]).timeout(const Duration(seconds: 5), onTimeout: () => const <void>[]);
}
Future<void> _handleBetaTimelineResume() async { Future<void> _handleBetaTimelineResume() async {
unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock()); unawaited(_ref.read(backgroundWorkerLockServiceProvider).lock());
_log.info("Handling beta timeline resume");
await _cancelSync();
// Give isolates time to complete any ongoing database transactions // Give isolates time to complete any ongoing database transactions
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
@@ -196,6 +208,7 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
Future<void> _performPause() { Future<void> _performPause() {
if (_ref.read(authProvider).isAuthenticated) { if (_ref.read(authProvider).isAuthenticated) {
_ref.read(driftBackupProvider.notifier).stopForegroundBackup(); _ref.read(driftBackupProvider.notifier).stopForegroundBackup();
unawaited(_cancelSync());
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
} }
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
final _log = Logger('TimelineProvider');
final timelineRepositoryProvider = Provider<DriftTimelineRepository>( final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)), (ref) => DriftTimelineRepository(ref.watch(driftProvider)),
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
(ref) { (ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? []; final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers); final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
ref.onDispose(timelineService.dispose); _log.info('main TimelineService built users=$timelineUsers');
ref.onDispose(() {
_log.info('main TimelineService disposed');
timelineService.dispose();
});
return timelineService; return timelineService;
}, },
// Empty dependencies to inform the framework that this provider // Empty dependencies to inform the framework that this provider
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
final timelineUsersProvider = StreamProvider<List<String>>((ref) { final timelineUsersProvider = StreamProvider<List<String>>((ref) {
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id)); final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
if (currentUserId == null) { if (currentUserId == null) {
_log.info('timelineUsers: currentUserId=null -> []');
return Stream.value([]); return Stream.value([]);
} }
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId); return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
_log.info('timelineUsers emission: $users');
return users;
});
}); });
+4 -4
View File
@@ -366,18 +366,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift name: drift
sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e" sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.33.0" version: "2.34.0"
drift_dev: drift_dev:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: drift_dev name: drift_dev
sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa sha256: "9cfff1576b49725da0d32c040651a41ae195e8c4af8d8da301593e41d7abc2f7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.33.0" version: "2.34.0"
drift_sqlite_async: drift_sqlite_async:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -2
View File
@@ -19,7 +19,7 @@ dependencies:
crypto: ^3.0.7 crypto: ^3.0.7
device_info_plus: ^12.4.0 device_info_plus: ^12.4.0
diacritic: ^0.1.6 diacritic: ^0.1.6
drift: ^2.32.1 drift: ^2.34.0
drift_sqlite_async: 0.3.1 drift_sqlite_async: 0.3.1
dynamic_color: ^1.8.1 dynamic_color: ^1.8.1
easy_localization: ^3.0.8 easy_localization: ^3.0.8
@@ -96,7 +96,7 @@ dev_dependencies:
auto_route_generator: ^10.5.0 auto_route_generator: ^10.5.0
build_runner: ^2.13.1 build_runner: ^2.13.1
# Drift generator # Drift generator
drift_dev: ^2.32.1 drift_dev: ^2.34.0
fake_async: ^1.3.3 fake_async: ^1.3.3
file: ^7.0.1 # for MemoryFileSystem file: ^7.0.1 # for MemoryFileSystem
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4