Merge b1ea976962 into 827bf1ef18
commit
5a1f90ac39
|
|
@ -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(
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
} else {
|
||||
startOffset += headerExtent;
|
||||
}
|
||||
final segmentEndOffset = startOffset;
|
||||
|
||||
segments.add(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue