From abe1ab0917b0c68cb8c068804cabcc181d6b8cdb Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 14 Dec 2025 14:07:57 +0100 Subject: [PATCH 01/11] first try --- mobile/lib/pages/photos/photos.page.dart | 7 + .../widgets/memory/memory_lane.widget.dart | 44 +++-- .../widgets/timeline/timeline.widget.dart | 184 +++++++++++++++--- .../widgets/asset_grid/immich_asset_grid.dart | 10 + .../asset_grid/immich_asset_grid_view.dart | 21 ++ .../widgets/asset_grid/multiselect_grid.dart | 35 ++-- mobile/lib/widgets/memories/memory_lane.dart | 55 ++++-- 7 files changed, 287 insertions(+), 69 deletions(-) diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 7f57247ec4672..36402bd3fb784 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -24,6 +24,13 @@ class PhotosPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // #region agent log + final mq = MediaQuery.of(context); + // Use debugPrint to avoid potential throttling differences vs print across platforms. + debugPrint( + 'AGENT_LOG PhotosPage.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} route=${ModalRoute.of(context)?.settings.name}', + ); + // #endregion final currentUser = ref.watch(currentUserProvider); final timelineUsers = ref.watch(timelineUsersIdsProvider); final tipOneOpacity = useState(0.0); diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index e85a6c05f8e6b..e459136c7febd 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -22,22 +22,36 @@ class DriftMemoryLane extends ConsumerWidget { return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), - child: CarouselView( - itemExtent: 145.0, - shrinkExtent: 1.0, - elevation: 2, - backgroundColor: Colors.black, - overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)), - onTap: (index) { - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - if (memories[index].assets.isNotEmpty) { - DriftMemoryPage.setMemory(ref, memories[index]); - } - context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)); + // CarouselView has been observed to trigger a framework assertion during rotation + // ('haveDimensions == (_lastMetrics != null)') in our runtime logs. + // Use a simple horizontal ListView to avoid that crash. + child: ListView.separated( + key: const PageStorageKey('drift-memory-lane-scroll'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: memories.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (ctx, index) { + return InkWell( + onTap: () { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + if (memories[index].assets.isNotEmpty) { + DriftMemoryPage.setMemory(ref, memories[index]); + } + context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index)); + }, + child: Material( + elevation: 2, + color: Colors.black, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 205, + height: 200, + child: DriftMemoryCard(key: Key(memories[index].id), memory: memories[index]), + ), + ), + ); }, - children: memories - .map((memory) => DriftMemoryCard(key: Key(memory.id), memory: memory)) - .toList(growable: false), ), ); } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index a04e26d653943..14e7d255747b9 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,6 +29,18 @@ 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'; +// Holds the live TimelineArgs for the current Timeline instance. +// We update this when LayoutBuilder constraints change (e.g. rotation) without recreating the widget subtree. +final _runtimeTimelineArgsProvider = StateProvider((ref) { + return const TimelineArgs(maxWidth: 0, maxHeight: 0); +}); + +// Updated continuously by the timeline grid to describe what's currently at the top of the viewport. +final _timelineAnchorAssetIndexProvider = StateProvider((ref) => null); + +// Set by the Timeline widget right before constraints/args change; consumed by _SliverTimelineState to restore view. +final _timelinePendingRestoreAssetIndexProvider = StateProvider((ref) => null); + class Timeline extends StatelessWidget { const Timeline({ super.key, @@ -57,33 +69,75 @@ class Timeline extends StatelessWidget { @override Widget build(BuildContext context) { + // #region agent log + final mq = MediaQuery.of(context); + debugPrint('AGENT_LOG Timeline.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation}'); + // #endregion return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( - builder: (_, constraints) => ProviderScope( - overrides: [ - timelineArgsProvider.overrideWith( - (ref) => TimelineArgs( - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), - showStorageIndicator: showStorageIndicator, - withStack: withStack, - groupBy: groupBy, - ), + builder: (_, constraints) { + // #region agent log + debugPrint( + 'AGENT_LOG Timeline.LayoutBuilder constraintsMaxWidth=${constraints.maxWidth} constraintsMaxHeight=${constraints.maxHeight}', + ); + // #endregion + return ProviderScope( + overrides: [ + // Make TimelineArgs dynamic: dependent widgets will rebuild when _runtimeTimelineArgsProvider changes. + timelineArgsProvider.overrideWith((ref) => ref.watch(_runtimeTimelineArgsProvider)), + ], + child: Consumer( + builder: (context, ref, _) { + final columnCount = ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))); + final desired = TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: columnCount, + showStorageIndicator: showStorageIndicator, + withStack: withStack, + groupBy: groupBy, + ); + final current = ref.watch(_runtimeTimelineArgsProvider); + + if (current != desired) { + final anchor = ref.read(_timelineAnchorAssetIndexProvider); + // #region agent log + debugPrint( + 'AGENT_LOG Timeline.constraintsChange captureAnchor assetIndex=$anchor currentArgs={w=${current.maxWidth},h=${current.maxHeight},c=${current.columnCount}} desiredArgs={w=${desired.maxWidth},h=${desired.maxHeight},c=${desired.columnCount}}', + ); + // #endregion + if (anchor != null) { + ref.read(_timelinePendingRestoreAssetIndexProvider.notifier).state = anchor; + } + // Update after this frame (avoid mutating provider state during widget build). + WidgetsBinding.instance.addPostFrameCallback((_) { + final latest = ref.read(_runtimeTimelineArgsProvider); + if (latest != desired) { + // #region agent log + debugPrint( + 'AGENT_LOG runtimeTimelineArgs.update from {w=${latest.maxWidth},h=${latest.maxHeight},c=${latest.columnCount}} to {w=${desired.maxWidth},h=${desired.maxHeight},c=${desired.columnCount}}', + ); + // #endregion + ref.read(_runtimeTimelineArgsProvider.notifier).state = desired; + } + }); + } + + return _SliverTimeline( + topSliverWidget: topSliverWidget, + topSliverWidgetHeight: topSliverWidgetHeight, + appBar: appBar, + bottomSheet: bottomSheet, + withScrubber: withScrubber, + snapToMonth: snapToMonth, + initialScrollOffset: initialScrollOffset, + ); + }, ), - ], - child: _SliverTimeline( - topSliverWidget: topSliverWidget, - topSliverWidgetHeight: topSliverWidgetHeight, - appBar: appBar, - bottomSheet: bottomSheet, - withScrubber: withScrubber, - snapToMonth: snapToMonth, - initialScrollOffset: initialScrollOffset, - ), - ), + ); + }, ), ); } @@ -115,6 +169,7 @@ class _SliverTimeline extends ConsumerStatefulWidget { class _SliverTimelineState extends ConsumerState<_SliverTimeline> { late final ScrollController _scrollController; StreamSubscription? _eventSubscription; + double? _lastKnownScrollOffset; // Drag selection state bool _dragging = false; @@ -130,9 +185,47 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void initState() { super.initState(); + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState.initState stateHash=${identityHashCode(this)} initialScrollOffset=${widget.initialScrollOffset ?? 0.0}', + ); + // #endregion _scrollController = ScrollController( initialScrollOffset: widget.initialScrollOffset ?? 0.0, - onAttach: _restoreScalePosition, + onAttach: (position) { + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState.scrollController.onAttach stateHash=${identityHashCode(this)} restoredOffset=${position.pixels}', + ); + // #endregion + // If we detach/reattach during rotation and the framework restores to 0 unexpectedly, + // jump back to the last known non-zero offset. + if (_lastKnownScrollOffset != null && + _lastKnownScrollOffset! > 0 && + (position.pixels == 0.0 || position.pixels.isNaN)) { + final target = _lastKnownScrollOffset!; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final max = _scrollController.position.maxScrollExtent; + final clamped = target.clamp(0.0, max); + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState.scrollController.onAttach fallbackJump target=$target clamped=$clamped max=$max', + ); + // #endregion + _scrollController.jumpTo(clamped); + }); + } + _restoreScalePosition(position); + }, + onDetach: (position) { + _lastKnownScrollOffset = position.pixels; + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState.scrollController.onDetach stateHash=${identityHashCode(this)} lastOffset=${position.pixels}', + ); + // #endregion + }, ); _eventSubscription = EventStream.shared.listen(_onEvent); @@ -142,6 +235,28 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _baseScaleFactor = _scaleFactor; ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); + + // When constraints change (rotation), restore the top-of-viewport anchor asset index after new layout is computed. + ref.listenManual(_timelinePendingRestoreAssetIndexProvider, (_, next) { + if (next == null) return; + // #region agent log + debugPrint('AGENT_LOG _SliverTimelineState.pendingRestore received assetIndex=$next'); + // #endregion + _scaleRestoreAssetIndex = next; + _restoreScalePosition(null); + // Clear so we don't re-run. + ref.read(_timelinePendingRestoreAssetIndexProvider.notifier).state = null; + }); + } + + int? _computeAnchorAssetIndex(List segments, double scrollOffset, int columnCount) { + final segment = segments.findByOffset(scrollOffset) ?? segments.lastOrNull; + if (segment == null) return null; + final rowIndex = segment.getMinChildIndexForScrollOffset(scrollOffset); + if (rowIndex < segment.firstIndex) return segment.firstAssetIndex; + final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); + final assetIndexInSegment = rowIndexInSegment * columnCount; + return segment.firstAssetIndex + assetIndexInSegment; } void _onEvent(Event event) { @@ -302,6 +417,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override Widget build(BuildContext _) { + // #region agent log + final mq = MediaQuery.of(context); + final args = ref.watch(timelineArgsProvider); + debugPrint( + 'AGENT_LOG _SliverTimelineState.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} args.maxWidth=${args.maxWidth} args.maxHeight=${args.maxHeight} args.columnCount=${args.columnCount} localPerRow=$_perRow scrollHasClients=${_scrollController.hasClients} scrollOffset=${_scrollController.hasClients ? _scrollController.offset : null}', + ); + // #endregion final asyncSegments = ref.watch(timelineSegmentProvider); final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); @@ -317,6 +439,19 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }, child: asyncSegments.widgetWhen( onData: (segments) { + // #region agent log + final currentOffset = _scrollController.hasClients + ? _scrollController.offset + : (widget.initialScrollOffset ?? 0.0); + final anchor = _computeAnchorAssetIndex(segments, currentOffset, args.columnCount); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(_timelineAnchorAssetIndexProvider.notifier).state = anchor; + }); + debugPrint( + 'AGENT_LOG _SliverTimelineState.anchorComputed offset=$currentOffset columnCount=${args.columnCount} anchorAssetIndex=$anchor', + ); + // #endregion final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar ? 200 @@ -331,6 +466,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); final grid = CustomScrollView( + // Preserve scroll position across transient detach/reattach during rotation/layout changes. + // Without a stable PageStorageKey, ScrollController can fall back to initialScrollOffset (0.0). + key: const PageStorageKey('timeline-grid-scroll'), primary: true, physics: _scrollPhysics, cacheExtent: maxHeight * 2, diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart index ab6b350a7b931..4098f163b50d4 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart @@ -64,6 +64,13 @@ class ImmichAssetGrid extends HookConsumerWidget { final scaleFactor = useState(7.0 - perRow.value); final baseScaleFactor = useState(7.0 - perRow.value); + // #region agent log + final mq = MediaQuery.of(context); + debugPrint( + 'AGENT_LOG ImmichAssetGrid.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} assetsPerRowProp=$assetsPerRow perRowState=${perRow.value} margin=$margin shrinkWrap=$shrinkWrap', + ); + // #endregion + /// assets need different hero tags across tabs / modals /// otherwise, hero animations are performed across tabs (looks buggy!) int heroOffset() { @@ -91,6 +98,9 @@ class ImmichAssetGrid extends HookConsumerWidget { if (7 - scaleFactor.value.toInt() != perRow.value) { perRow.value = 7 - scaleFactor.value.toInt(); settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value); + // #region agent log + debugPrint('AGENT_LOG ImmichAssetGrid.scale.onUpdate perRowChanged=${perRow.value}'); + // #endregion } }; }, diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 7db03a33aa465..61b9c817d4da6 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -310,6 +310,11 @@ class ImmichAssetGridViewState extends ConsumerState { @override void didUpdateWidget(ImmichAssetGridView oldWidget) { super.didUpdateWidget(oldWidget); + // #region agent log + debugPrint( + 'AGENT_LOG ImmichAssetGridViewState.didUpdateWidget oldAssetsPerRow=${oldWidget.assetsPerRow} newAssetsPerRow=${widget.assetsPerRow}', + ); + // #endregion if (!widget.selectionActive) { setState(() { _selectedAssets.clear(); @@ -493,6 +498,12 @@ class ImmichAssetGridViewState extends ConsumerState { @override Widget build(BuildContext context) { + // #region agent log + final mq = MediaQuery.of(context); + debugPrint( + 'AGENT_LOG ImmichAssetGridViewState.build assetsPerRow=${widget.assetsPerRow} w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} route=${ModalRoute.of(context)?.settings.name}', + ); + // #endregion return PopScope( canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty), onPopInvokedWithResult: (didPop, _) { @@ -604,6 +615,11 @@ class _Section extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + // #region agent log + debugPrint( + 'AGENT_LOG _Section.build constraintsMaxWidth=${constraints.maxWidth} constraintsMaxHeight=${constraints.maxHeight} assetsPerRow=$assetsPerRow margin=$margin sectionCount=${section.count} sectionType=${section.type}', + ); + // #endregion final width = constraints.maxWidth / assetsPerRow - margin * (assetsPerRow - 1) / assetsPerRow; final rows = (section.count + assetsPerRow - 1) ~/ assetsPerRow; final List assetsToRender = scrolling ? [] : renderList.loadAssets(section.offset, section.count); @@ -755,6 +771,11 @@ class _AssetRow extends StatelessWidget { @override Widget build(BuildContext context) { + // #region agent log + debugPrint( + 'AGENT_LOG _AssetRow.build assetsLen=${assets.length} width=$width assetsPerRow=$assetsPerRow dynamicLayout=$dynamicLayout margin=$margin absoluteOffset=$absoluteOffset', + ); + // #endregion // Default: All assets have the same width final widthDistribution = List.filled(assets.length, 1.0); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index c0d8a6bea2aa5..2e85024b25890 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -71,6 +71,12 @@ class MultiselectGrid extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // #region agent log + final mq = MediaQuery.of(context); + debugPrint( + 'AGENT_LOG MultiselectGrid.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} route=${ModalRoute.of(context)?.settings.name}', + ); + // #endregion final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); final selectionAssetState = useState(const AssetSelectionState()); @@ -408,17 +414,24 @@ class MultiselectGrid extends HookConsumerWidget { ref .watch(renderListProvider) .when( - data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) - ? (buildLoadingIndicator ?? buildEmptyIndicator)() - : ImmichAssetGrid( - renderList: data, - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - onRefresh: onRefresh == null ? null : wrapLongRunningFun(onRefresh!, showOverlay: false), - topWidget: topWidget, - showStack: stackEnabled, - showDragScrollLabel: dragScrollLabelEnabled, - ), + data: (data) { + // #region agent log + debugPrint( + 'AGENT_LOG MultiselectGrid.renderList.when(data) isEmpty=${data.isEmpty} elements=${data.elements.length} totalAssets=${data.totalAssets}', + ); + // #endregion + return data.isEmpty && (buildLoadingIndicator != null || topWidget == null) + ? (buildLoadingIndicator ?? buildEmptyIndicator)() + : ImmichAssetGrid( + renderList: data, + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + onRefresh: onRefresh == null ? null : wrapLongRunningFun(onRefresh!, showOverlay: false), + topWidget: topWidget, + showStack: stackEnabled, + showDragScrollLabel: dragScrollLabelEnabled, + ); + }, error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, ), diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 727950fd86e22..41e38b648fc46 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -1,5 +1,4 @@ import 'package:auto_route/auto_route.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; @@ -23,26 +22,42 @@ class MemoryLane extends HookConsumerWidget { (memories) => memories != null ? ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), - child: CarouselView( - itemExtent: 145.0, - shrinkExtent: 1.0, - elevation: 2, - backgroundColor: Colors.black, - overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)), - onTap: (memoryIndex) { - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - if (memories[memoryIndex].assets.isNotEmpty) { - final asset = memories[memoryIndex].assets[0]; - ref.read(currentAssetProvider.notifier).set(asset); - if (asset.isVideo || asset.isMotionPhoto) { - ref.read(videoPlaybackValueProvider.notifier).reset(); - } - } - context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex)); + // CarouselView has been observed to trigger a framework assertion during rotation + // ('haveDimensions == (_lastMetrics != null)') in our runtime logs. + // Use a simple horizontal ListView to avoid that crash and preserve timeline scroll state. + child: ListView.builder( + key: const PageStorageKey('memory-lane-scroll'), + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: memories.length, + itemBuilder: (ctx, memoryIndex) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + onTap: () { + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + if (memories[memoryIndex].assets.isNotEmpty) { + final asset = memories[memoryIndex].assets[0]; + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo || asset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); + } + } + context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex)); + }, + child: Material( + elevation: 2, + color: Colors.black, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 205, + height: 200, + child: MemoryCard(index: memoryIndex, memory: memories[memoryIndex]), + ), + ), + ), + ); }, - children: memories - .mapIndexed((index, memory) => MemoryCard(index: index, memory: memory)) - .toList(), ), ) : const SizedBox(), From 8f80f207074040156416c122bdd0989b7d941e3f Mon Sep 17 00:00:00 2001 From: timonrieger Date: Sun, 14 Dec 2025 14:26:25 +0100 Subject: [PATCH 02/11] second try --- .../timeline/fixed/segment_builder.dart | 30 +++- .../widgets/timeline/timeline.widget.dart | 124 +++++++++++-- .../timeline/fixed_segment_builder_test.dart | 168 ++++++++++++++++++ 3 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart index b65582f976081..bc2b5b76456ad 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -42,7 +42,17 @@ class FixedSegmentBuilder extends SegmentBuilder { final headerExtent = SegmentBuilder.headerExtent(timelineHeader); final segmentStartOffset = startOffset; - startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1); + // Compute endOffset to match FixedSegment.gridOffset semantics: + // - gridOffset = startOffset + headerExtent + spacing (for first grid row) + // - endOffset should be: gridOffset + (mainAxisExtend * (numberOfRows - 1)) + tileHeight + // where mainAxisExtend = tileHeight + spacing + // This simplifies to: startOffset + headerExtent + spacing + tileHeight * numberOfRows + spacing * (numberOfRows - 1) + if (numberOfRows > 0) { + startOffset += headerExtent + spacing + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1); + } else { + // If no rows, endOffset is just after the header (no spacing needed) + startOffset += headerExtent; + } final segmentEndOffset = startOffset; segments.add( @@ -66,6 +76,24 @@ class FixedSegmentBuilder extends SegmentBuilder { previousDate = bucket.date; } } + + // Debug validation: ensure segments have monotonic, non-overlapping offsets + assert(() { + for (int i = 0; i < segments.length - 1; i++) { + final current = segments[i]; + final next = segments[i + 1]; + assert( + current.endOffset <= next.startOffset, + 'Segment offset overlap: segment $i endOffset=${current.endOffset} > segment ${i + 1} startOffset=${next.startOffset}', + ); + assert( + current.startOffset <= current.endOffset, + 'Segment $i has invalid offset range: startOffset=${current.startOffset} > endOffset=${current.endOffset}', + ); + } + return true; + }(), 'Segment offset validation failed'); + return segments; } } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 14e7d255747b9..e20837f880d71 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -41,6 +41,25 @@ final _timelineAnchorAssetIndexProvider = StateProvider((ref) => null); // Set by the Timeline widget right before constraints/args change; consumed by _SliverTimelineState to restore view. final _timelinePendingRestoreAssetIndexProvider = StateProvider((ref) => null); +/// Represents a row anchor for scroll position restoration. +/// The rowIndex is the sliver child index (includes headers), and deltaPx is the +/// offset within that row (0 if the row is at the top edge). +class _TimelineRowAnchor { + final int rowIndex; + final double deltaPx; + + const _TimelineRowAnchor({required this.rowIndex, required this.deltaPx}); + + @override + String toString() => '_TimelineRowAnchor(rowIndex: $rowIndex, deltaPx: $deltaPx)'; +} + +// Updated continuously by the timeline grid to describe the row at the top of the viewport. +final _timelineAnchorRowProvider = StateProvider<_TimelineRowAnchor?>((ref) => null); + +// Set by the Timeline widget right before constraints/args change; consumed by _SliverTimelineState to restore view. +final _timelinePendingRestoreRowAnchorProvider = StateProvider<_TimelineRowAnchor?>((ref) => null); + class Timeline extends StatelessWidget { const Timeline({ super.key, @@ -102,15 +121,12 @@ class Timeline extends StatelessWidget { final current = ref.watch(_runtimeTimelineArgsProvider); if (current != desired) { - final anchor = ref.read(_timelineAnchorAssetIndexProvider); + final rowAnchor = ref.read(_timelineAnchorRowProvider); // #region agent log debugPrint( - 'AGENT_LOG Timeline.constraintsChange captureAnchor assetIndex=$anchor currentArgs={w=${current.maxWidth},h=${current.maxHeight},c=${current.columnCount}} desiredArgs={w=${desired.maxWidth},h=${desired.maxHeight},c=${desired.columnCount}}', + 'AGENT_LOG Timeline.constraintsChange captureAnchor rowAnchor=$rowAnchor currentArgs={w=${current.maxWidth},h=${current.maxHeight},c=${current.columnCount}} desiredArgs={w=${desired.maxWidth},h=${desired.maxHeight},c=${desired.columnCount}}', ); // #endregion - if (anchor != null) { - ref.read(_timelinePendingRestoreAssetIndexProvider.notifier).state = anchor; - } // Update after this frame (avoid mutating provider state during widget build). WidgetsBinding.instance.addPostFrameCallback((_) { final latest = ref.read(_runtimeTimelineArgsProvider); @@ -122,6 +138,10 @@ class Timeline extends StatelessWidget { // #endregion ref.read(_runtimeTimelineArgsProvider.notifier).state = desired; } + // Set pending restore after updating args (also deferred to avoid build-time modification) + if (rowAnchor != null) { + ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = rowAnchor; + } }); } @@ -181,6 +201,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { double _scaleFactor = 3.0; double _baseScaleFactor = 3.0; int? _scaleRestoreAssetIndex; + _TimelineRowAnchor? _pendingRestoreRowAnchor; + bool _hasPendingRowAnchorRestore = false; @override void initState() { @@ -195,12 +217,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { onAttach: (position) { // #region agent log debugPrint( - 'AGENT_LOG _SliverTimelineState.scrollController.onAttach stateHash=${identityHashCode(this)} restoredOffset=${position.pixels}', + 'AGENT_LOG _SliverTimelineState.scrollController.onAttach stateHash=${identityHashCode(this)} restoredOffset=${position.pixels} hasPendingRowAnchor=$_hasPendingRowAnchorRestore', ); // #endregion - // If we detach/reattach during rotation and the framework restores to 0 unexpectedly, - // jump back to the last known non-zero offset. - if (_lastKnownScrollOffset != null && + // If we have a pending row anchor restore, skip the pixel fallback to avoid conflicts. + if (!_hasPendingRowAnchorRestore && + _lastKnownScrollOffset != null && _lastKnownScrollOffset! > 0 && (position.pixels == 0.0 || position.pixels.isNaN)) { final target = _lastKnownScrollOffset!; @@ -217,6 +239,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); } _restoreScalePosition(position); + _restoreRowAnchor(); }, onDetach: (position) { _lastKnownScrollOffset = position.pixels; @@ -247,6 +270,19 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // Clear so we don't re-run. ref.read(_timelinePendingRestoreAssetIndexProvider.notifier).state = null; }); + + // When constraints change (rotation), restore the row anchor after new layout is computed. + ref.listenManual(_timelinePendingRestoreRowAnchorProvider, (_, next) { + if (next == null) return; + // #region agent log + debugPrint('AGENT_LOG _SliverTimelineState.pendingRestoreRowAnchor received rowAnchor=$next'); + // #endregion + _pendingRestoreRowAnchor = next; + _hasPendingRowAnchorRestore = true; + _restoreRowAnchor(); + // Clear so we don't re-run. + ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = null; + }); } int? _computeAnchorAssetIndex(List segments, double scrollOffset, int columnCount) { @@ -259,6 +295,18 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return segment.firstAssetIndex + assetIndexInSegment; } + /// Computes the row anchor (rowIndex + deltaPx) for the current scroll position. + /// This is used for stable scroll restoration during orientation changes. + _TimelineRowAnchor? _computeRowAnchor(List segments, double scrollOffset) { + final segment = segments.findByOffset(scrollOffset) ?? segments.lastOrNull; + if (segment == null) return null; + final rowIndex = segment.getMinChildIndexForScrollOffset(scrollOffset); + final rowOffset = segment.indexToLayoutOffset(rowIndex); + // Delta is the offset within the row (clamped to >= 0 to handle edge cases) + final deltaPx = (scrollOffset - rowOffset).clamp(0.0, double.infinity); + return _TimelineRowAnchor(rowIndex: rowIndex, deltaPx: deltaPx); + } + void _onEvent(Event event) { switch (event) { case ScrollToTopEvent(): @@ -302,6 +350,60 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _scaleRestoreAssetIndex = null; } + /// Restores scroll position using the row anchor (rowIndex + deltaPx). + /// This provides stable restoration during orientation changes by keeping the same row at the top. + void _restoreRowAnchor() { + if (_pendingRestoreRowAnchor == null || !_hasPendingRowAnchorRestore) return; + + final asyncSegments = ref.read(timelineSegmentProvider); + asyncSegments.whenData((segments) { + if (segments.isEmpty) return; + + final rowAnchor = _pendingRestoreRowAnchor!; + // Find the segment that contains the target row index + final targetSegment = segments.findByIndex(rowAnchor.rowIndex); + if (targetSegment == null) { + // If row index is out of bounds, clamp to valid range + final lastSegment = segments.lastOrNull; + if (lastSegment == null) return; + final clampedRowIndex = rowAnchor.rowIndex.clamp(0, lastSegment.lastIndex); + final fallbackSegment = segments.findByIndex(clampedRowIndex); + if (fallbackSegment == null) return; + final targetOffset = fallbackSegment.indexToLayoutOffset(clampedRowIndex) + rowAnchor.deltaPx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final max = _scrollController.position.maxScrollExtent; + final clamped = targetOffset.clamp(0.0, max); + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState._restoreRowAnchor clampedRowIndex=$clampedRowIndex targetOffset=$targetOffset clamped=$clamped', + ); + // #endregion + _scrollController.jumpTo(clamped); + _hasPendingRowAnchorRestore = false; + _pendingRestoreRowAnchor = null; + }); + return; + } + + // Compute the target offset: row's layout offset + delta within the row + final targetOffset = targetSegment.indexToLayoutOffset(rowAnchor.rowIndex) + rowAnchor.deltaPx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final max = _scrollController.position.maxScrollExtent; + final clamped = targetOffset.clamp(0.0, max); + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState._restoreRowAnchor rowIndex=${rowAnchor.rowIndex} deltaPx=${rowAnchor.deltaPx} targetOffset=$targetOffset clamped=$clamped', + ); + // #endregion + _scrollController.jumpTo(clamped); + _hasPendingRowAnchorRestore = false; + _pendingRestoreRowAnchor = null; + }); + }); + } + @override void dispose() { _scrollController.dispose(); @@ -444,12 +546,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ? _scrollController.offset : (widget.initialScrollOffset ?? 0.0); final anchor = _computeAnchorAssetIndex(segments, currentOffset, args.columnCount); + final rowAnchor = _computeRowAnchor(segments, currentOffset); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(_timelineAnchorAssetIndexProvider.notifier).state = anchor; + ref.read(_timelineAnchorRowProvider.notifier).state = rowAnchor; }); debugPrint( - 'AGENT_LOG _SliverTimelineState.anchorComputed offset=$currentOffset columnCount=${args.columnCount} anchorAssetIndex=$anchor', + 'AGENT_LOG _SliverTimelineState.anchorComputed offset=$currentOffset columnCount=${args.columnCount} anchorAssetIndex=$anchor rowAnchor=$rowAnchor', ); // #endregion final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; diff --git a/mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart b/mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart new file mode 100644 index 0000000000000..3f4234792e651 --- /dev/null +++ b/mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; + +void main() { + group('FixedSegmentBuilder', () { + test('should generate segments with monotonic, non-overlapping offsets', () { + const tileHeight = 100.0; + const columnCount = 3; + const spacing = 2.0; + + final buckets = [ + TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), + TimeBucket(date: DateTime(2024, 1, 2), assetCount: 7), + TimeBucket(date: DateTime(2024, 1, 3), assetCount: 2), + ]; + + final builder = FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileHeight, + columnCount: columnCount, + spacing: spacing, + groupBy: GroupAssetsBy.day, + ); + + final segments = builder.generate(); + + // Verify segments are non-empty + expect(segments.length, greaterThan(0)); + + // Verify monotonic, non-overlapping offsets + for (int i = 0; i < segments.length - 1; i++) { + final current = segments[i]; + final next = segments[i + 1]; + + expect( + current.endOffset, + lessThanOrEqualTo(next.startOffset), + reason: 'Segment $i endOffset should be <= segment ${i + 1} startOffset', + ); + expect( + current.startOffset, + lessThanOrEqualTo(current.endOffset), + reason: 'Segment $i should have valid offset range', + ); + } + }); + + test('should handle empty buckets correctly', () { + const tileHeight = 100.0; + const columnCount = 3; + const spacing = 2.0; + + final buckets = [ + TimeBucket(date: DateTime(2024, 1, 1), assetCount: 0), + TimeBucket(date: DateTime(2024, 1, 2), assetCount: 5), + ]; + + final builder = FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileHeight, + columnCount: columnCount, + spacing: spacing, + groupBy: GroupAssetsBy.day, + ); + + final segments = builder.generate(); + + expect(segments.length, equals(2)); + + // Verify offsets are still monotonic even with empty bucket + expect( + segments[0].endOffset, + lessThanOrEqualTo(segments[1].startOffset), + ); + }); + + test('should compute correct gridOffset alignment', () { + const tileHeight = 100.0; + const columnCount = 3; + const spacing = 2.0; + + final buckets = [ + TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), + ]; + + final builder = FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileHeight, + columnCount: columnCount, + spacing: spacing, + groupBy: GroupAssetsBy.day, + ); + + final segments = builder.generate(); + expect(segments.length, equals(1)); + + final segment = segments[0] as FixedSegment; + final expectedGridOffset = segment.startOffset + segment.headerExtent + spacing; + + expect(segment.gridOffset, equals(expectedGridOffset)); + }); + + test('should handle findByOffset correctly at segment boundaries', () { + const tileHeight = 100.0; + const columnCount = 3; + const spacing = 2.0; + + final buckets = [ + TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), + TimeBucket(date: DateTime(2024, 1, 2), assetCount: 7), + ]; + + final builder = FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileHeight, + columnCount: columnCount, + spacing: spacing, + groupBy: GroupAssetsBy.day, + ); + + final segments = builder.generate(); + + // Test at boundary between segments + final boundaryOffset = segments[0].endOffset; + final foundSegment = segments.findByOffset(boundaryOffset); + + // Should find a segment (either the first or second, depending on implementation) + expect(foundSegment, isNotNull); + expect(foundSegment!.isWithinOffset(boundaryOffset), isTrue); + }); + + test('should compute indexToLayoutOffset monotonically within segment', () { + const tileHeight = 100.0; + const columnCount = 3; + const spacing = 2.0; + + final buckets = [ + TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), + ]; + + final builder = FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileHeight, + columnCount: columnCount, + spacing: spacing, + groupBy: GroupAssetsBy.day, + ); + + final segments = builder.generate(); + final segment = segments[0]; + + // Verify monotonicity: each index should have offset >= previous + for (int i = segment.firstIndex; i < segment.lastIndex; i++) { + final currentOffset = segment.indexToLayoutOffset(i); + final nextOffset = segment.indexToLayoutOffset(i + 1); + expect( + nextOffset, + greaterThanOrEqualTo(currentOffset), + reason: 'indexToLayoutOffset should be monotonic', + ); + } + }); + }); +} + From 30073b1411d1004c7b67b50705cfe3d90efeb131 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 16:35:49 +0100 Subject: [PATCH 03/11] fix(mobile): anchor-based scroll preservation for timeline rotation Implement continuous row anchor tracking and automatic scroll adjustment when segments change, matching web timeline behavior. This ensures stable scroll position during orientation changes by using (rowIndex, deltaPx) anchors instead of unreliable pixel offsets. - Add _onScroll() listener to continuously update row anchor - Auto-adjust scroll when segments regenerate (width/columnCount change) - Remove pixel-based fallback restoration logic --- .../widgets/timeline/timeline.widget.dart | 121 +++++++++++------- 1 file changed, 74 insertions(+), 47 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index e20837f880d71..492e57d3c2feb 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -35,9 +35,6 @@ final _runtimeTimelineArgsProvider = StateProvider((ref) { return const TimelineArgs(maxWidth: 0, maxHeight: 0); }); -// Updated continuously by the timeline grid to describe what's currently at the top of the viewport. -final _timelineAnchorAssetIndexProvider = StateProvider((ref) => null); - // Set by the Timeline widget right before constraints/args change; consumed by _SliverTimelineState to restore view. final _timelinePendingRestoreAssetIndexProvider = StateProvider((ref) => null); @@ -189,7 +186,6 @@ class _SliverTimeline extends ConsumerStatefulWidget { class _SliverTimelineState extends ConsumerState<_SliverTimeline> { late final ScrollController _scrollController; StreamSubscription? _eventSubscription; - double? _lastKnownScrollOffset; // Drag selection state bool _dragging = false; @@ -220,29 +216,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { 'AGENT_LOG _SliverTimelineState.scrollController.onAttach stateHash=${identityHashCode(this)} restoredOffset=${position.pixels} hasPendingRowAnchor=$_hasPendingRowAnchorRestore', ); // #endregion - // If we have a pending row anchor restore, skip the pixel fallback to avoid conflicts. - if (!_hasPendingRowAnchorRestore && - _lastKnownScrollOffset != null && - _lastKnownScrollOffset! > 0 && - (position.pixels == 0.0 || position.pixels.isNaN)) { - final target = _lastKnownScrollOffset!; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollController.hasClients) return; - final max = _scrollController.position.maxScrollExtent; - final clamped = target.clamp(0.0, max); - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState.scrollController.onAttach fallbackJump target=$target clamped=$clamped max=$max', - ); - // #endregion - _scrollController.jumpTo(clamped); - }); - } + // Add scroll listener to continuously update row anchor (similar to web's updateIntersections) + _scrollController.addListener(_onScroll); _restoreScalePosition(position); _restoreRowAnchor(); }, onDetach: (position) { - _lastKnownScrollOffset = position.pixels; + _scrollController.removeListener(_onScroll); // #region agent log debugPrint( 'AGENT_LOG _SliverTimelineState.scrollController.onDetach stateHash=${identityHashCode(this)} lastOffset=${position.pixels}', @@ -283,16 +263,63 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // Clear so we don't re-run. ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = null; }); - } - int? _computeAnchorAssetIndex(List segments, double scrollOffset, int columnCount) { - final segment = segments.findByOffset(scrollOffset) ?? segments.lastOrNull; - if (segment == null) return null; - final rowIndex = segment.getMinChildIndexForScrollOffset(scrollOffset); - if (rowIndex < segment.firstIndex) return segment.firstAssetIndex; - final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); - final assetIndexInSegment = rowIndexInSegment * columnCount; - return segment.firstAssetIndex + assetIndexInSegment; + // When segments change (due to width/columnCount change), automatically adjust scroll + // to maintain the current row anchor (similar to web's MonthGroup.height setter). + // We use a separate listener that watches the segments directly to avoid AsyncValue null issues. + ref.listenManual(timelineSegmentProvider.select((async) => async.valueOrNull), (previous, next) { + // Only process when both have data + if (previous == null || next == null) return; + + // Only adjust if segments actually changed (not just a rebuild) + if (previous.equals(next)) return; + + final currentAnchor = ref.read(_timelineAnchorRowProvider); + if (currentAnchor == null) return; + + if (next.isEmpty) return; + + final targetSegment = next.findByIndex(currentAnchor.rowIndex); + if (targetSegment == null) { + // Segment changed, try to find closest + final lastSegment = next.lastOrNull; + if (lastSegment == null) return; + final clampedRowIndex = currentAnchor.rowIndex.clamp(0, lastSegment.lastIndex); + final fallbackSegment = next.findByIndex(clampedRowIndex); + if (fallbackSegment == null) return; + final targetOffset = fallbackSegment.indexToLayoutOffset(clampedRowIndex) + currentAnchor.deltaPx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final max = _scrollController.position.maxScrollExtent; + final clamped = targetOffset.clamp(0.0, max); + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState.segmentsChanged autoAdjust fallback clampedRowIndex=$clampedRowIndex targetOffset=$targetOffset clamped=$clamped', + ); + // #endregion + _scrollController.jumpTo(clamped); + }); + return; + } + + // Compute the target offset: row's layout offset + delta within the row + final targetOffset = targetSegment.indexToLayoutOffset(currentAnchor.rowIndex) + currentAnchor.deltaPx; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final max = _scrollController.position.maxScrollExtent; + final clamped = targetOffset.clamp(0.0, max); + // Only adjust if the current scroll position doesn't match the target + final currentOffset = _scrollController.offset; + if ((clamped - currentOffset).abs() > 1.0) { + // #region agent log + debugPrint( + 'AGENT_LOG _SliverTimelineState.segmentsChanged autoAdjust rowIndex=${currentAnchor.rowIndex} deltaPx=${currentAnchor.deltaPx} currentOffset=$currentOffset targetOffset=$targetOffset clamped=$clamped', + ); + // #endregion + _scrollController.jumpTo(clamped); + } + }); + }); } /// Computes the row anchor (rowIndex + deltaPx) for the current scroll position. @@ -307,6 +334,21 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return _TimelineRowAnchor(rowIndex: rowIndex, deltaPx: deltaPx); } + /// Continuously updates the row anchor during scroll (similar to web's updateIntersections). + /// This ensures we always have an accurate anchor when geometry changes (e.g., orientation). + void _onScroll() { + if (!_scrollController.hasClients) return; + final scrollOffset = _scrollController.offset; + final asyncSegments = ref.read(timelineSegmentProvider); + asyncSegments.whenData((segments) { + if (segments.isEmpty) return; + final rowAnchor = _computeRowAnchor(segments, scrollOffset); + if (rowAnchor != null) { + ref.read(_timelineAnchorRowProvider.notifier).state = rowAnchor; + } + }); + } + void _onEvent(Event event) { switch (event) { case ScrollToTopEvent(): @@ -541,21 +583,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }, child: asyncSegments.widgetWhen( onData: (segments) { - // #region agent log - final currentOffset = _scrollController.hasClients - ? _scrollController.offset - : (widget.initialScrollOffset ?? 0.0); - final anchor = _computeAnchorAssetIndex(segments, currentOffset, args.columnCount); - final rowAnchor = _computeRowAnchor(segments, currentOffset); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - ref.read(_timelineAnchorAssetIndexProvider.notifier).state = anchor; - ref.read(_timelineAnchorRowProvider.notifier).state = rowAnchor; - }); - debugPrint( - 'AGENT_LOG _SliverTimelineState.anchorComputed offset=$currentOffset columnCount=${args.columnCount} anchorAssetIndex=$anchor rowAnchor=$rowAnchor', - ); - // #endregion final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar ? 200 From 7993848c7684720aa9fdb2972f5ff9a1d7a4d8a8 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 16:42:12 +0100 Subject: [PATCH 04/11] remove debug logs --- mobile/lib/pages/photos/photos.page.dart | 7 -- .../widgets/timeline/timeline.widget.dart | 67 ------------------- .../widgets/asset_grid/immich_asset_grid.dart | 10 --- .../asset_grid/immich_asset_grid_view.dart | 21 ------ .../widgets/asset_grid/multiselect_grid.dart | 35 +++------- 5 files changed, 11 insertions(+), 129 deletions(-) diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 36402bd3fb784..7f57247ec4672 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -24,13 +24,6 @@ class PhotosPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // #region agent log - final mq = MediaQuery.of(context); - // Use debugPrint to avoid potential throttling differences vs print across platforms. - debugPrint( - 'AGENT_LOG PhotosPage.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} route=${ModalRoute.of(context)?.settings.name}', - ); - // #endregion final currentUser = ref.watch(currentUserProvider); final timelineUsers = ref.watch(timelineUsersIdsProvider); final tipOneOpacity = useState(0.0); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 492e57d3c2feb..ee69dc4dbbc14 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -85,20 +85,11 @@ class Timeline extends StatelessWidget { @override Widget build(BuildContext context) { - // #region agent log - final mq = MediaQuery.of(context); - debugPrint('AGENT_LOG Timeline.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation}'); - // #endregion return Scaffold( resizeToAvoidBottomInset: false, floatingActionButton: const DownloadStatusFloatingButton(), body: LayoutBuilder( builder: (_, constraints) { - // #region agent log - debugPrint( - 'AGENT_LOG Timeline.LayoutBuilder constraintsMaxWidth=${constraints.maxWidth} constraintsMaxHeight=${constraints.maxHeight}', - ); - // #endregion return ProviderScope( overrides: [ // Make TimelineArgs dynamic: dependent widgets will rebuild when _runtimeTimelineArgsProvider changes. @@ -119,20 +110,10 @@ class Timeline extends StatelessWidget { if (current != desired) { final rowAnchor = ref.read(_timelineAnchorRowProvider); - // #region agent log - debugPrint( - 'AGENT_LOG Timeline.constraintsChange captureAnchor rowAnchor=$rowAnchor currentArgs={w=${current.maxWidth},h=${current.maxHeight},c=${current.columnCount}} desiredArgs={w=${desired.maxWidth},h=${desired.maxHeight},c=${desired.columnCount}}', - ); - // #endregion // Update after this frame (avoid mutating provider state during widget build). WidgetsBinding.instance.addPostFrameCallback((_) { final latest = ref.read(_runtimeTimelineArgsProvider); if (latest != desired) { - // #region agent log - debugPrint( - 'AGENT_LOG runtimeTimelineArgs.update from {w=${latest.maxWidth},h=${latest.maxHeight},c=${latest.columnCount}} to {w=${desired.maxWidth},h=${desired.maxHeight},c=${desired.columnCount}}', - ); - // #endregion ref.read(_runtimeTimelineArgsProvider.notifier).state = desired; } // Set pending restore after updating args (also deferred to avoid build-time modification) @@ -203,19 +184,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override void initState() { super.initState(); - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState.initState stateHash=${identityHashCode(this)} initialScrollOffset=${widget.initialScrollOffset ?? 0.0}', - ); - // #endregion _scrollController = ScrollController( initialScrollOffset: widget.initialScrollOffset ?? 0.0, onAttach: (position) { - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState.scrollController.onAttach stateHash=${identityHashCode(this)} restoredOffset=${position.pixels} hasPendingRowAnchor=$_hasPendingRowAnchorRestore', - ); - // #endregion // Add scroll listener to continuously update row anchor (similar to web's updateIntersections) _scrollController.addListener(_onScroll); _restoreScalePosition(position); @@ -223,11 +194,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }, onDetach: (position) { _scrollController.removeListener(_onScroll); - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState.scrollController.onDetach stateHash=${identityHashCode(this)} lastOffset=${position.pixels}', - ); - // #endregion }, ); _eventSubscription = EventStream.shared.listen(_onEvent); @@ -242,9 +208,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // When constraints change (rotation), restore the top-of-viewport anchor asset index after new layout is computed. ref.listenManual(_timelinePendingRestoreAssetIndexProvider, (_, next) { if (next == null) return; - // #region agent log - debugPrint('AGENT_LOG _SliverTimelineState.pendingRestore received assetIndex=$next'); - // #endregion _scaleRestoreAssetIndex = next; _restoreScalePosition(null); // Clear so we don't re-run. @@ -254,9 +217,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // When constraints change (rotation), restore the row anchor after new layout is computed. ref.listenManual(_timelinePendingRestoreRowAnchorProvider, (_, next) { if (next == null) return; - // #region agent log - debugPrint('AGENT_LOG _SliverTimelineState.pendingRestoreRowAnchor received rowAnchor=$next'); - // #endregion _pendingRestoreRowAnchor = next; _hasPendingRowAnchorRestore = true; _restoreRowAnchor(); @@ -292,11 +252,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { if (!mounted || !_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; final clamped = targetOffset.clamp(0.0, max); - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState.segmentsChanged autoAdjust fallback clampedRowIndex=$clampedRowIndex targetOffset=$targetOffset clamped=$clamped', - ); - // #endregion _scrollController.jumpTo(clamped); }); return; @@ -311,11 +266,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // Only adjust if the current scroll position doesn't match the target final currentOffset = _scrollController.offset; if ((clamped - currentOffset).abs() > 1.0) { - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState.segmentsChanged autoAdjust rowIndex=${currentAnchor.rowIndex} deltaPx=${currentAnchor.deltaPx} currentOffset=$currentOffset targetOffset=$targetOffset clamped=$clamped', - ); - // #endregion _scrollController.jumpTo(clamped); } }); @@ -416,11 +366,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { if (!mounted || !_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; final clamped = targetOffset.clamp(0.0, max); - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState._restoreRowAnchor clampedRowIndex=$clampedRowIndex targetOffset=$targetOffset clamped=$clamped', - ); - // #endregion _scrollController.jumpTo(clamped); _hasPendingRowAnchorRestore = false; _pendingRestoreRowAnchor = null; @@ -434,11 +379,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { if (!mounted || !_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; final clamped = targetOffset.clamp(0.0, max); - // #region agent log - debugPrint( - 'AGENT_LOG _SliverTimelineState._restoreRowAnchor rowIndex=${rowAnchor.rowIndex} deltaPx=${rowAnchor.deltaPx} targetOffset=$targetOffset clamped=$clamped', - ); - // #endregion _scrollController.jumpTo(clamped); _hasPendingRowAnchorRestore = false; _pendingRestoreRowAnchor = null; @@ -561,13 +501,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { @override Widget build(BuildContext _) { - // #region agent log - final mq = MediaQuery.of(context); - final args = ref.watch(timelineArgsProvider); - debugPrint( - 'AGENT_LOG _SliverTimelineState.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} args.maxWidth=${args.maxWidth} args.maxHeight=${args.maxHeight} args.columnCount=${args.columnCount} localPerRow=$_perRow scrollHasClients=${_scrollController.hasClients} scrollOffset=${_scrollController.hasClients ? _scrollController.offset : null}', - ); - // #endregion final asyncSegments = ref.watch(timelineSegmentProvider); final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart index 4098f163b50d4..ab6b350a7b931 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart @@ -64,13 +64,6 @@ class ImmichAssetGrid extends HookConsumerWidget { final scaleFactor = useState(7.0 - perRow.value); final baseScaleFactor = useState(7.0 - perRow.value); - // #region agent log - final mq = MediaQuery.of(context); - debugPrint( - 'AGENT_LOG ImmichAssetGrid.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} assetsPerRowProp=$assetsPerRow perRowState=${perRow.value} margin=$margin shrinkWrap=$shrinkWrap', - ); - // #endregion - /// assets need different hero tags across tabs / modals /// otherwise, hero animations are performed across tabs (looks buggy!) int heroOffset() { @@ -98,9 +91,6 @@ class ImmichAssetGrid extends HookConsumerWidget { if (7 - scaleFactor.value.toInt() != perRow.value) { perRow.value = 7 - scaleFactor.value.toInt(); settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value); - // #region agent log - debugPrint('AGENT_LOG ImmichAssetGrid.scale.onUpdate perRowChanged=${perRow.value}'); - // #endregion } }; }, diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 61b9c817d4da6..7db03a33aa465 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -310,11 +310,6 @@ class ImmichAssetGridViewState extends ConsumerState { @override void didUpdateWidget(ImmichAssetGridView oldWidget) { super.didUpdateWidget(oldWidget); - // #region agent log - debugPrint( - 'AGENT_LOG ImmichAssetGridViewState.didUpdateWidget oldAssetsPerRow=${oldWidget.assetsPerRow} newAssetsPerRow=${widget.assetsPerRow}', - ); - // #endregion if (!widget.selectionActive) { setState(() { _selectedAssets.clear(); @@ -498,12 +493,6 @@ class ImmichAssetGridViewState extends ConsumerState { @override Widget build(BuildContext context) { - // #region agent log - final mq = MediaQuery.of(context); - debugPrint( - 'AGENT_LOG ImmichAssetGridViewState.build assetsPerRow=${widget.assetsPerRow} w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} route=${ModalRoute.of(context)?.settings.name}', - ); - // #endregion return PopScope( canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty), onPopInvokedWithResult: (didPop, _) { @@ -615,11 +604,6 @@ class _Section extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - // #region agent log - debugPrint( - 'AGENT_LOG _Section.build constraintsMaxWidth=${constraints.maxWidth} constraintsMaxHeight=${constraints.maxHeight} assetsPerRow=$assetsPerRow margin=$margin sectionCount=${section.count} sectionType=${section.type}', - ); - // #endregion final width = constraints.maxWidth / assetsPerRow - margin * (assetsPerRow - 1) / assetsPerRow; final rows = (section.count + assetsPerRow - 1) ~/ assetsPerRow; final List assetsToRender = scrolling ? [] : renderList.loadAssets(section.offset, section.count); @@ -771,11 +755,6 @@ class _AssetRow extends StatelessWidget { @override Widget build(BuildContext context) { - // #region agent log - debugPrint( - 'AGENT_LOG _AssetRow.build assetsLen=${assets.length} width=$width assetsPerRow=$assetsPerRow dynamicLayout=$dynamicLayout margin=$margin absoluteOffset=$absoluteOffset', - ); - // #endregion // Default: All assets have the same width final widthDistribution = List.filled(assets.length, 1.0); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 2e85024b25890..c0d8a6bea2aa5 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -71,12 +71,6 @@ class MultiselectGrid extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // #region agent log - final mq = MediaQuery.of(context); - debugPrint( - 'AGENT_LOG MultiselectGrid.build w=${mq.size.width} h=${mq.size.height} orientation=${mq.orientation} route=${ModalRoute.of(context)?.settings.name}', - ); - // #endregion final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); final selectionAssetState = useState(const AssetSelectionState()); @@ -414,24 +408,17 @@ class MultiselectGrid extends HookConsumerWidget { ref .watch(renderListProvider) .when( - data: (data) { - // #region agent log - debugPrint( - 'AGENT_LOG MultiselectGrid.renderList.when(data) isEmpty=${data.isEmpty} elements=${data.elements.length} totalAssets=${data.totalAssets}', - ); - // #endregion - return data.isEmpty && (buildLoadingIndicator != null || topWidget == null) - ? (buildLoadingIndicator ?? buildEmptyIndicator)() - : ImmichAssetGrid( - renderList: data, - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - onRefresh: onRefresh == null ? null : wrapLongRunningFun(onRefresh!, showOverlay: false), - topWidget: topWidget, - showStack: stackEnabled, - showDragScrollLabel: dragScrollLabelEnabled, - ); - }, + data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null) + ? (buildLoadingIndicator ?? buildEmptyIndicator)() + : ImmichAssetGrid( + renderList: data, + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + onRefresh: onRefresh == null ? null : wrapLongRunningFun(onRefresh!, showOverlay: false), + topWidget: topWidget, + showStack: stackEnabled, + showDragScrollLabel: dragScrollLabelEnabled, + ), error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, ), From c2add9ee30d4bce798ec843cfc8d1f51761b2d2e Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 16:45:35 +0100 Subject: [PATCH 05/11] lint and format --- mobile/lib/presentation/widgets/memory/memory_lane.widget.dart | 2 +- mobile/lib/widgets/memories/memory_lane.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index e459136c7febd..d909723cac60c 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -43,7 +43,7 @@ class DriftMemoryLane extends ConsumerWidget { child: Material( elevation: 2, color: Colors.black, - borderRadius: BorderRadius.circular(12), + borderRadius: const BorderRadius.all(Radius.circular(12)), child: SizedBox( width: 205, height: 200, diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index 41e38b648fc46..c7a92f6efc3d0 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -48,7 +48,7 @@ class MemoryLane extends HookConsumerWidget { child: Material( elevation: 2, color: Colors.black, - borderRadius: BorderRadius.circular(12), + borderRadius: const BorderRadius.all(Radius.circular(12)), child: SizedBox( width: 205, height: 200, From cf3d7aeb43947607c941e03544e5fc57c1e2eb0f Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 16:51:17 +0100 Subject: [PATCH 06/11] remove stale test --- .../timeline/fixed_segment_builder_test.dart | 168 ------------------ 1 file changed, 168 deletions(-) delete mode 100644 mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart diff --git a/mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart b/mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart deleted file mode 100644 index 3f4234792e651..0000000000000 --- a/mobile/test/presentation/widgets/timeline/fixed_segment_builder_test.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/domain/models/timeline.model.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; - -void main() { - group('FixedSegmentBuilder', () { - test('should generate segments with monotonic, non-overlapping offsets', () { - const tileHeight = 100.0; - const columnCount = 3; - const spacing = 2.0; - - final buckets = [ - TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), - TimeBucket(date: DateTime(2024, 1, 2), assetCount: 7), - TimeBucket(date: DateTime(2024, 1, 3), assetCount: 2), - ]; - - final builder = FixedSegmentBuilder( - buckets: buckets, - tileHeight: tileHeight, - columnCount: columnCount, - spacing: spacing, - groupBy: GroupAssetsBy.day, - ); - - final segments = builder.generate(); - - // Verify segments are non-empty - expect(segments.length, greaterThan(0)); - - // Verify monotonic, non-overlapping offsets - for (int i = 0; i < segments.length - 1; i++) { - final current = segments[i]; - final next = segments[i + 1]; - - expect( - current.endOffset, - lessThanOrEqualTo(next.startOffset), - reason: 'Segment $i endOffset should be <= segment ${i + 1} startOffset', - ); - expect( - current.startOffset, - lessThanOrEqualTo(current.endOffset), - reason: 'Segment $i should have valid offset range', - ); - } - }); - - test('should handle empty buckets correctly', () { - const tileHeight = 100.0; - const columnCount = 3; - const spacing = 2.0; - - final buckets = [ - TimeBucket(date: DateTime(2024, 1, 1), assetCount: 0), - TimeBucket(date: DateTime(2024, 1, 2), assetCount: 5), - ]; - - final builder = FixedSegmentBuilder( - buckets: buckets, - tileHeight: tileHeight, - columnCount: columnCount, - spacing: spacing, - groupBy: GroupAssetsBy.day, - ); - - final segments = builder.generate(); - - expect(segments.length, equals(2)); - - // Verify offsets are still monotonic even with empty bucket - expect( - segments[0].endOffset, - lessThanOrEqualTo(segments[1].startOffset), - ); - }); - - test('should compute correct gridOffset alignment', () { - const tileHeight = 100.0; - const columnCount = 3; - const spacing = 2.0; - - final buckets = [ - TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), - ]; - - final builder = FixedSegmentBuilder( - buckets: buckets, - tileHeight: tileHeight, - columnCount: columnCount, - spacing: spacing, - groupBy: GroupAssetsBy.day, - ); - - final segments = builder.generate(); - expect(segments.length, equals(1)); - - final segment = segments[0] as FixedSegment; - final expectedGridOffset = segment.startOffset + segment.headerExtent + spacing; - - expect(segment.gridOffset, equals(expectedGridOffset)); - }); - - test('should handle findByOffset correctly at segment boundaries', () { - const tileHeight = 100.0; - const columnCount = 3; - const spacing = 2.0; - - final buckets = [ - TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), - TimeBucket(date: DateTime(2024, 1, 2), assetCount: 7), - ]; - - final builder = FixedSegmentBuilder( - buckets: buckets, - tileHeight: tileHeight, - columnCount: columnCount, - spacing: spacing, - groupBy: GroupAssetsBy.day, - ); - - final segments = builder.generate(); - - // Test at boundary between segments - final boundaryOffset = segments[0].endOffset; - final foundSegment = segments.findByOffset(boundaryOffset); - - // Should find a segment (either the first or second, depending on implementation) - expect(foundSegment, isNotNull); - expect(foundSegment!.isWithinOffset(boundaryOffset), isTrue); - }); - - test('should compute indexToLayoutOffset monotonically within segment', () { - const tileHeight = 100.0; - const columnCount = 3; - const spacing = 2.0; - - final buckets = [ - TimeBucket(date: DateTime(2024, 1, 1), assetCount: 5), - ]; - - final builder = FixedSegmentBuilder( - buckets: buckets, - tileHeight: tileHeight, - columnCount: columnCount, - spacing: spacing, - groupBy: GroupAssetsBy.day, - ); - - final segments = builder.generate(); - final segment = segments[0]; - - // Verify monotonicity: each index should have offset >= previous - for (int i = segment.firstIndex; i < segment.lastIndex; i++) { - final currentOffset = segment.indexToLayoutOffset(i); - final nextOffset = segment.indexToLayoutOffset(i + 1); - expect( - nextOffset, - greaterThanOrEqualTo(currentOffset), - reason: 'indexToLayoutOffset should be monotonic', - ); - } - }); - }); -} - From 126e8b1c8a804fc3ec5e7b0cae36eb522b32e879 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 16:56:47 +0100 Subject: [PATCH 07/11] remove debug comments --- .../widgets/memory/memory_lane.widget.dart | 3 -- .../timeline/fixed/segment_builder.dart | 7 ---- .../widgets/timeline/timeline.widget.dart | 40 +------------------ 3 files changed, 1 insertion(+), 49 deletions(-) diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index d909723cac60c..5e49e0ab6e34d 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -22,9 +22,6 @@ class DriftMemoryLane extends ConsumerWidget { return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), - // CarouselView has been observed to trigger a framework assertion during rotation - // ('haveDimensions == (_lastMetrics != null)') in our runtime logs. - // Use a simple horizontal ListView to avoid that crash. child: ListView.separated( key: const PageStorageKey('drift-memory-lane-scroll'), scrollDirection: Axis.horizontal, diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart index bc2b5b76456ad..acea606ec3e07 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -42,15 +42,9 @@ class FixedSegmentBuilder extends SegmentBuilder { final headerExtent = SegmentBuilder.headerExtent(timelineHeader); final segmentStartOffset = startOffset; - // Compute endOffset to match FixedSegment.gridOffset semantics: - // - gridOffset = startOffset + headerExtent + spacing (for first grid row) - // - endOffset should be: gridOffset + (mainAxisExtend * (numberOfRows - 1)) + tileHeight - // where mainAxisExtend = tileHeight + spacing - // This simplifies to: startOffset + headerExtent + spacing + tileHeight * numberOfRows + spacing * (numberOfRows - 1) if (numberOfRows > 0) { startOffset += headerExtent + spacing + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1); } else { - // If no rows, endOffset is just after the header (no spacing needed) startOffset += headerExtent; } final segmentEndOffset = startOffset; @@ -77,7 +71,6 @@ class FixedSegmentBuilder extends SegmentBuilder { } } - // Debug validation: ensure segments have monotonic, non-overlapping offsets assert(() { for (int i = 0; i < segments.length - 1; i++) { final current = segments[i]; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index ee69dc4dbbc14..bd96e2282d09c 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,18 +29,12 @@ 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'; -// Holds the live TimelineArgs for the current Timeline instance. -// We update this when LayoutBuilder constraints change (e.g. rotation) without recreating the widget subtree. final _runtimeTimelineArgsProvider = StateProvider((ref) { return const TimelineArgs(maxWidth: 0, maxHeight: 0); }); -// Set by the Timeline widget right before constraints/args change; consumed by _SliverTimelineState to restore view. final _timelinePendingRestoreAssetIndexProvider = StateProvider((ref) => null); -/// Represents a row anchor for scroll position restoration. -/// The rowIndex is the sliver child index (includes headers), and deltaPx is the -/// offset within that row (0 if the row is at the top edge). class _TimelineRowAnchor { final int rowIndex; final double deltaPx; @@ -51,10 +45,8 @@ class _TimelineRowAnchor { String toString() => '_TimelineRowAnchor(rowIndex: $rowIndex, deltaPx: $deltaPx)'; } -// Updated continuously by the timeline grid to describe the row at the top of the viewport. final _timelineAnchorRowProvider = StateProvider<_TimelineRowAnchor?>((ref) => null); -// Set by the Timeline widget right before constraints/args change; consumed by _SliverTimelineState to restore view. final _timelinePendingRestoreRowAnchorProvider = StateProvider<_TimelineRowAnchor?>((ref) => null); class Timeline extends StatelessWidget { @@ -91,10 +83,7 @@ class Timeline extends StatelessWidget { body: LayoutBuilder( builder: (_, constraints) { return ProviderScope( - overrides: [ - // Make TimelineArgs dynamic: dependent widgets will rebuild when _runtimeTimelineArgsProvider changes. - timelineArgsProvider.overrideWith((ref) => ref.watch(_runtimeTimelineArgsProvider)), - ], + overrides: [timelineArgsProvider.overrideWith((ref) => ref.watch(_runtimeTimelineArgsProvider))], child: Consumer( builder: (context, ref, _) { final columnCount = ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))); @@ -110,13 +99,11 @@ class Timeline extends StatelessWidget { if (current != desired) { final rowAnchor = ref.read(_timelineAnchorRowProvider); - // Update after this frame (avoid mutating provider state during widget build). WidgetsBinding.instance.addPostFrameCallback((_) { final latest = ref.read(_runtimeTimelineArgsProvider); if (latest != desired) { ref.read(_runtimeTimelineArgsProvider.notifier).state = desired; } - // Set pending restore after updating args (also deferred to avoid build-time modification) if (rowAnchor != null) { ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = rowAnchor; } @@ -187,7 +174,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _scrollController = ScrollController( initialScrollOffset: widget.initialScrollOffset ?? 0.0, onAttach: (position) { - // Add scroll listener to continuously update row anchor (similar to web's updateIntersections) _scrollController.addListener(_onScroll); _restoreScalePosition(position); _restoreRowAnchor(); @@ -205,33 +191,24 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); - // When constraints change (rotation), restore the top-of-viewport anchor asset index after new layout is computed. ref.listenManual(_timelinePendingRestoreAssetIndexProvider, (_, next) { if (next == null) return; _scaleRestoreAssetIndex = next; _restoreScalePosition(null); - // Clear so we don't re-run. ref.read(_timelinePendingRestoreAssetIndexProvider.notifier).state = null; }); - // When constraints change (rotation), restore the row anchor after new layout is computed. ref.listenManual(_timelinePendingRestoreRowAnchorProvider, (_, next) { if (next == null) return; _pendingRestoreRowAnchor = next; _hasPendingRowAnchorRestore = true; _restoreRowAnchor(); - // Clear so we don't re-run. ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = null; }); - // When segments change (due to width/columnCount change), automatically adjust scroll - // to maintain the current row anchor (similar to web's MonthGroup.height setter). - // We use a separate listener that watches the segments directly to avoid AsyncValue null issues. ref.listenManual(timelineSegmentProvider.select((async) => async.valueOrNull), (previous, next) { - // Only process when both have data if (previous == null || next == null) return; - // Only adjust if segments actually changed (not just a rebuild) if (previous.equals(next)) return; final currentAnchor = ref.read(_timelineAnchorRowProvider); @@ -241,7 +218,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { final targetSegment = next.findByIndex(currentAnchor.rowIndex); if (targetSegment == null) { - // Segment changed, try to find closest final lastSegment = next.lastOrNull; if (lastSegment == null) return; final clampedRowIndex = currentAnchor.rowIndex.clamp(0, lastSegment.lastIndex); @@ -257,13 +233,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return; } - // Compute the target offset: row's layout offset + delta within the row final targetOffset = targetSegment.indexToLayoutOffset(currentAnchor.rowIndex) + currentAnchor.deltaPx; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; final clamped = targetOffset.clamp(0.0, max); - // Only adjust if the current scroll position doesn't match the target final currentOffset = _scrollController.offset; if ((clamped - currentOffset).abs() > 1.0) { _scrollController.jumpTo(clamped); @@ -272,20 +246,15 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); } - /// Computes the row anchor (rowIndex + deltaPx) for the current scroll position. - /// This is used for stable scroll restoration during orientation changes. _TimelineRowAnchor? _computeRowAnchor(List segments, double scrollOffset) { final segment = segments.findByOffset(scrollOffset) ?? segments.lastOrNull; if (segment == null) return null; final rowIndex = segment.getMinChildIndexForScrollOffset(scrollOffset); final rowOffset = segment.indexToLayoutOffset(rowIndex); - // Delta is the offset within the row (clamped to >= 0 to handle edge cases) final deltaPx = (scrollOffset - rowOffset).clamp(0.0, double.infinity); return _TimelineRowAnchor(rowIndex: rowIndex, deltaPx: deltaPx); } - /// Continuously updates the row anchor during scroll (similar to web's updateIntersections). - /// This ensures we always have an accurate anchor when geometry changes (e.g., orientation). void _onScroll() { if (!_scrollController.hasClients) return; final scrollOffset = _scrollController.offset; @@ -342,8 +311,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _scaleRestoreAssetIndex = null; } - /// Restores scroll position using the row anchor (rowIndex + deltaPx). - /// This provides stable restoration during orientation changes by keeping the same row at the top. void _restoreRowAnchor() { if (_pendingRestoreRowAnchor == null || !_hasPendingRowAnchorRestore) return; @@ -352,10 +319,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { if (segments.isEmpty) return; final rowAnchor = _pendingRestoreRowAnchor!; - // Find the segment that contains the target row index final targetSegment = segments.findByIndex(rowAnchor.rowIndex); if (targetSegment == null) { - // If row index is out of bounds, clamp to valid range final lastSegment = segments.lastOrNull; if (lastSegment == null) return; final clampedRowIndex = rowAnchor.rowIndex.clamp(0, lastSegment.lastIndex); @@ -373,7 +338,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return; } - // Compute the target offset: row's layout offset + delta within the row final targetOffset = targetSegment.indexToLayoutOffset(rowAnchor.rowIndex) + rowAnchor.deltaPx; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) return; @@ -530,8 +494,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { (isMultiSelectEnabled ? bottomSheetOpenModifier : 0); final grid = CustomScrollView( - // Preserve scroll position across transient detach/reattach during rotation/layout changes. - // Without a stable PageStorageKey, ScrollController can fall back to initialScrollOffset (0.0). key: const PageStorageKey('timeline-grid-scroll'), primary: true, physics: _scrollPhysics, From ad9a60426cd0ffbe71ab1d35a4eb1858f6353f8e Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 17:08:51 +0100 Subject: [PATCH 08/11] remove another debug section --- .../widgets/timeline/fixed/segment_builder.dart | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart index acea606ec3e07..d5188c85f9c7f 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -71,22 +71,6 @@ class FixedSegmentBuilder extends SegmentBuilder { } } - assert(() { - for (int i = 0; i < segments.length - 1; i++) { - final current = segments[i]; - final next = segments[i + 1]; - assert( - current.endOffset <= next.startOffset, - 'Segment offset overlap: segment $i endOffset=${current.endOffset} > segment ${i + 1} startOffset=${next.startOffset}', - ); - assert( - current.startOffset <= current.endOffset, - 'Segment $i has invalid offset range: startOffset=${current.startOffset} > endOffset=${current.endOffset}', - ); - } - return true; - }(), 'Segment offset validation failed'); - return segments; } } From e7baf4437ac60c8140d7bb6122bba9748eaac01a Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 17:13:14 +0100 Subject: [PATCH 09/11] remove unused code block --- .../presentation/widgets/timeline/timeline.widget.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index bd96e2282d09c..483c84adc583e 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -33,8 +33,6 @@ final _runtimeTimelineArgsProvider = StateProvider((ref) { return const TimelineArgs(maxWidth: 0, maxHeight: 0); }); -final _timelinePendingRestoreAssetIndexProvider = StateProvider((ref) => null); - class _TimelineRowAnchor { final int rowIndex; final double deltaPx; @@ -191,13 +189,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled); - ref.listenManual(_timelinePendingRestoreAssetIndexProvider, (_, next) { - if (next == null) return; - _scaleRestoreAssetIndex = next; - _restoreScalePosition(null); - ref.read(_timelinePendingRestoreAssetIndexProvider.notifier).state = null; - }); - ref.listenManual(_timelinePendingRestoreRowAnchorProvider, (_, next) { if (next == null) return; _pendingRestoreRowAnchor = next; From 4343a764a7c7fc344b484c06975600a8eeb6b32a Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 17:19:50 +0100 Subject: [PATCH 10/11] remove whitespace --- .../lib/presentation/widgets/timeline/fixed/segment_builder.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart index d5188c85f9c7f..50feba5e8d81b 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -70,7 +70,6 @@ class FixedSegmentBuilder extends SegmentBuilder { previousDate = bucket.date; } } - return segments; } } From b1ea976962bcf610e3a05d7399d9712125517411 Mon Sep 17 00:00:00 2001 From: timonrieger Date: Wed, 17 Dec 2025 17:21:30 +0100 Subject: [PATCH 11/11] remove comment --- mobile/lib/widgets/memories/memory_lane.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/mobile/lib/widgets/memories/memory_lane.dart b/mobile/lib/widgets/memories/memory_lane.dart index c7a92f6efc3d0..7d390d872f492 100644 --- a/mobile/lib/widgets/memories/memory_lane.dart +++ b/mobile/lib/widgets/memories/memory_lane.dart @@ -22,9 +22,6 @@ class MemoryLane extends HookConsumerWidget { (memories) => memories != null ? ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), - // CarouselView has been observed to trigger a framework assertion during rotation - // ('haveDimensions == (_lastMetrics != null)') in our runtime logs. - // Use a simple horizontal ListView to avoid that crash and preserve timeline scroll state. child: ListView.builder( key: const PageStorageKey('memory-lane-scroll'), scrollDirection: Axis.horizontal,