Skip to content
41 changes: 26 additions & 15 deletions mobile/lib/presentation/widgets/memory/memory_lane.widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,33 @@ 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));
child: ListView.separated(
Copy link
Collaborator Author

@timonrieger timonrieger Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-review: CarouselView triggered a framework assertion during rotation ('haveDimensions == (_lastMetrics != null)')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A ListView is different from CarouselView. We should be fixing the issue and not replace it with a different widget

key: const PageStorageKey<String>('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: const BorderRadius.all(Radius.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),
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ class FixedSegmentBuilder extends SegmentBuilder {
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);

final segmentStartOffset = startOffset;
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
if (numberOfRows > 0) {
startOffset += headerExtent + spacing + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was the duplicate spacing intentional?

Suggested change
startOffset += headerExtent + spacing + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);

} else {
startOffset += headerExtent;
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self-review: When numberOfRows == 0, there are no asset rows, so we only add the header extent and skip spacing/tile heights that don't apply.

final segmentEndOffset = startOffset;

segments.add(
Expand Down
201 changes: 178 additions & 23 deletions mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,24 @@ 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';

final _runtimeTimelineArgsProvider = StateProvider<TimelineArgs>((ref) {
return const TimelineArgs(maxWidth: 0, maxHeight: 0);
});

class _TimelineRowAnchor {
final int rowIndex;
final double deltaPx;

const _TimelineRowAnchor({required this.rowIndex, required this.deltaPx});

@override
String toString() => '_TimelineRowAnchor(rowIndex: $rowIndex, deltaPx: $deltaPx)';
}

final _timelineAnchorRowProvider = StateProvider<_TimelineRowAnchor?>((ref) => null);

final _timelinePendingRestoreRowAnchorProvider = StateProvider<_TimelineRowAnchor?>((ref) => null);

class Timeline extends StatelessWidget {
const Timeline({
super.key,
Expand Down Expand Up @@ -61,29 +79,48 @@ class Timeline extends StatelessWidget {
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) {
return ProviderScope(
overrides: [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 rowAnchor = ref.read(_timelineAnchorRowProvider);
WidgetsBinding.instance.addPostFrameCallback((_) {
final latest = ref.read(_runtimeTimelineArgsProvider);
if (latest != desired) {
ref.read(_runtimeTimelineArgsProvider.notifier).state = desired;
}
if (rowAnchor != null) {
ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = rowAnchor;
}
});
}
Comment on lines +85 to +109
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why'd we need all these complex rebuild flows? All we need to do is to rebuild the widget on orientation state while retaining the anchor. It should actually be possible with just a single provider that stores the required anchor before rebuild and restores it after the change. Can you look into simplifying all the provider logic? Or can you explain why we need each of the providers?


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,
),
),
);
},
),
);
}
Expand Down Expand Up @@ -126,13 +163,22 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
double _scaleFactor = 3.0;
double _baseScaleFactor = 3.0;
int? _scaleRestoreAssetIndex;
_TimelineRowAnchor? _pendingRestoreRowAnchor;
bool _hasPendingRowAnchorRestore = false;

@override
void initState() {
super.initState();
_scrollController = ScrollController(
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
onAttach: _restoreScalePosition,
onAttach: (position) {
_scrollController.addListener(_onScroll);
_restoreScalePosition(position);
_restoreRowAnchor();
},
onDetach: (position) {
_scrollController.removeListener(_onScroll);
},
);
_eventSubscription = EventStream.shared.listen(_onEvent);

Expand All @@ -142,6 +188,75 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
_baseScaleFactor = _scaleFactor;

ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);

ref.listenManual(_timelinePendingRestoreRowAnchorProvider, (_, next) {
if (next == null) return;
_pendingRestoreRowAnchor = next;
_hasPendingRowAnchorRestore = true;
_restoreRowAnchor();
ref.read(_timelinePendingRestoreRowAnchorProvider.notifier).state = null;
});

ref.listenManual(timelineSegmentProvider.select((async) => async.valueOrNull), (previous, next) {
if (previous == null || next == null) return;

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) {
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);
_scrollController.jumpTo(clamped);
});
return;
}

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);
final currentOffset = _scrollController.offset;
if ((clamped - currentOffset).abs() > 1.0) {
_scrollController.jumpTo(clamped);
}
});
});
}

_TimelineRowAnchor? _computeRowAnchor(List<Segment> 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);
final deltaPx = (scrollOffset - rowOffset).clamp(0.0, double.infinity);
return _TimelineRowAnchor(rowIndex: rowIndex, deltaPx: deltaPx);
}

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) {
Expand Down Expand Up @@ -187,6 +302,45 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
_scaleRestoreAssetIndex = null;
}

void _restoreRowAnchor() {
if (_pendingRestoreRowAnchor == null || !_hasPendingRowAnchorRestore) return;

final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
if (segments.isEmpty) return;

final rowAnchor = _pendingRestoreRowAnchor!;
final targetSegment = segments.findByIndex(rowAnchor.rowIndex);
if (targetSegment == null) {
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);
_scrollController.jumpTo(clamped);
_hasPendingRowAnchorRestore = false;
_pendingRestoreRowAnchor = null;
});
return;
}

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);
_scrollController.jumpTo(clamped);
_hasPendingRowAnchorRestore = false;
_pendingRestoreRowAnchor = null;
});
});
}

@override
void dispose() {
_scrollController.dispose();
Expand Down Expand Up @@ -331,6 +485,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);

final grid = CustomScrollView(
key: const PageStorageKey<String>('timeline-grid-scroll'),
primary: true,
physics: _scrollPhysics,
cacheExtent: maxHeight * 2,
Expand Down
52 changes: 32 additions & 20 deletions mobile/lib/widgets/memories/memory_lane.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,26 +22,39 @@ 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));
child: ListView.builder(
key: const PageStorageKey<String>('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: const BorderRadius.all(Radius.circular(12)),
child: SizedBox(
width: 205,
height: 200,
child: MemoryCard(index: memoryIndex, memory: memories[memoryIndex]),
),
),
),
);
},
children: memories
.mapIndexed<Widget>((index, memory) => MemoryCard(index: index, memory: memory))
.toList(),
),
)
: const SizedBox(),
Expand Down
Loading