diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 74299305f6..daccdad8fd 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -91,6 +91,7 @@ class FixedSegment extends Segment { } class _FixedSegmentRow extends ConsumerWidget { + static final Logger _log = Logger('TimelineRow'); final int assetIndex; final int assetCount; final double tileHeight; @@ -110,8 +111,20 @@ class _FixedSegmentRow extends ConsumerWidget { final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); final timelineService = ref.read(timelineServiceProvider); 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( context, timelineService.getAssets(assetIndex, assetCount), @@ -131,7 +144,7 @@ class _FixedSegmentRow extends ConsumerWidget { return _buildPlaceholder(context); } if (snapshot.hasError) { - Logger('TimelineService').warning( + _log.warning( 'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})', snapshot.error, snapshot.stackTrace, diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index 27f523a2a8..f9320c1fe2 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -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/utils/debounce.dart'; 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 /// for quick navigation of the BoxScrollView. @@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List layoutSegments, required d } class ScrubberState extends ConsumerState with TickerProviderStateMixin { + static final Logger _log = Logger('Scrubber'); String? _lastLabel; double _thumbTopOffset = 0.0; bool _isDragging = false; @@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi @override void initState() { super.initState(); + _log.info('Scrubber initState'); _isDragging = false; _segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight); _thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration); @@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi void didUpdateWidget(covariant Scrubber 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); _monthCount = getMonthCount(); } @@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi @override 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(); _labelAnimationController.dispose(); _fadeOutTimer?.cancel(); @@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi } void _onDragStart(DragStartDetails _) { + _log.info('scrub dragStart'); setState(() { _isDragging = true; _labelAnimationController.forward(); @@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi } if (_scrubberHeight <= 0) { + _log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0'); 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) { _thumbAnimationController.forward(); } @@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState with TickerProviderStateMixi } void _onDragEnd(DragEndDetails _) { + _log.info('scrub dragEnd -> setScrubbing(false)'); _labelAnimationController.reverse(); setState(() { _isDragging = false; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index f2d412d759..d5dfa80292 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -72,14 +72,27 @@ class TimelineState { } class TimelineStateNotifier extends Notifier { + static final Logger _log = Logger('TimelineState'); + void setScrubbing(bool isScrubbing) { + if (state.isScrubbing != isScrubbing) { + _log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})'); + } state = state.copyWith(isScrubbing: isScrubbing); } void setScrolling(bool isScrolling) { + if (state.isScrolling != isScrolling) { + _log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})'); + } state = state.copyWith(isScrolling: isScrolling); } + static String _callSite() { + final frames = StackTrace.current.toString().split('\n'); + return frames.length > 2 ? frames[2].trim() : 'unknown'; + } + @override TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false); } @@ -99,8 +112,8 @@ final timelineSegmentProvider = StreamProvider.autoDispose>((ref) yield* timelineService.watchBuckets().map((buckets) { final layoutTotal = buckets.fold(0, (acc, bucket) => acc + bucket.assetCount); Logger('TimelineService').info( - '[${timelineService.origin}] segment layout: ${buckets.length} buckets / $layoutTotal assets ' - '(service.totalAssets=${timelineService.totalAssets})', + '[${timelineService.origin}] segment layout: ' + '${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})', ); return FixedSegmentBuilder( buckets: buckets, diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 8acaebf40e..c0654b36f7 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -28,6 +28,7 @@ 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 { const Timeline({ @@ -136,6 +137,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { } class _SliverTimelineState extends ConsumerState<_SliverTimeline> { + static final Logger _log = Logger('Timeline'); late final ScrollController _scrollController; StreamSubscription? _eventSubscription; @@ -153,6 +155,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void initState() { super.initState(); + _log.info('SliverTimeline initState'); _scrollController = ScrollController(onAttach: _restoreAssetPosition); _eventSubscription = EventStream.shared.listen(_onEvent); @@ -179,6 +182,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } void _onEvent(Event event) { + _log.info('event ${event.runtimeType}'); switch (event) { case ScrollToTopEvent(): { @@ -186,7 +190,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { timelineState.setScrubbing(true); _scrollController .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: @@ -246,6 +253,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void dispose() { + _log.info('SliverTimeline dispose'); _scrollController.dispose(); _eventSubscription?.cancel(); super.dispose(); @@ -286,8 +294,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, ) - .whenComplete(() => timelineState.setScrubbing(false)); + .whenComplete(() { + _log.info('ScrollToDate animation done -> setScrubbing(false)'); + timelineState.setScrubbing(false); + }); } else { + _log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)'); timelineState.setScrubbing(false); } }); diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index b22c693033..708fd6a507 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -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/settings.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('TimelineProvider'); final timelineRepositoryProvider = Provider( (ref) => DriftTimelineRepository(ref.watch(driftProvider)), @@ -18,7 +21,11 @@ final timelineServiceProvider = Provider( (ref) { final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? []; 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; }, // Empty dependencies to inform the framework that this provider @@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider( final timelineUsersProvider = StreamProvider>((ref) { final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id)); if (currentUserId == null) { + _log.info('timelineUsers: currentUserId=null -> []'); return Stream.value([]); } - return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId); + return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) { + _log.info('timelineUsers emission: $users'); + return users; + }); });