Merge b1ea976962 into 827bf1ef18
commit
5a1f90ac39
|
|
@ -22,22 +22,33 @@ class DriftMemoryLane extends ConsumerWidget {
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
child: CarouselView(
|
child: ListView.separated(
|
||||||
itemExtent: 145.0,
|
key: const PageStorageKey<String>('drift-memory-lane-scroll'),
|
||||||
shrinkExtent: 1.0,
|
scrollDirection: Axis.horizontal,
|
||||||
elevation: 2,
|
physics: const BouncingScrollPhysics(),
|
||||||
backgroundColor: Colors.black,
|
itemCount: memories.length,
|
||||||
overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)),
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
onTap: (index) {
|
itemBuilder: (ctx, index) {
|
||||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
return InkWell(
|
||||||
if (memories[index].assets.isNotEmpty) {
|
onTap: () {
|
||||||
DriftMemoryPage.setMemory(ref, memories[index]);
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||||
}
|
if (memories[index].assets.isNotEmpty) {
|
||||||
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
|
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 headerExtent = SegmentBuilder.headerExtent(timelineHeader);
|
||||||
|
|
||||||
final segmentStartOffset = startOffset;
|
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;
|
final segmentEndOffset = startOffset;
|
||||||
|
|
||||||
segments.add(
|
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/mesmerizing_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
|
||||||
|
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 {
|
class Timeline extends StatelessWidget {
|
||||||
const Timeline({
|
const Timeline({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -61,29 +79,48 @@ class Timeline extends StatelessWidget {
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
floatingActionButton: const DownloadStatusFloatingButton(),
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) {
|
||||||
overrides: [
|
return ProviderScope(
|
||||||
timelineArgsProvider.overrideWith(
|
overrides: [timelineArgsProvider.overrideWith((ref) => ref.watch(_runtimeTimelineArgsProvider))],
|
||||||
(ref) => TimelineArgs(
|
child: Consumer(
|
||||||
maxWidth: constraints.maxWidth,
|
builder: (context, ref, _) {
|
||||||
maxHeight: constraints.maxHeight,
|
final columnCount = ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow)));
|
||||||
columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))),
|
final desired = TimelineArgs(
|
||||||
showStorageIndicator: showStorageIndicator,
|
maxWidth: constraints.maxWidth,
|
||||||
withStack: withStack,
|
maxHeight: constraints.maxHeight,
|
||||||
groupBy: groupBy,
|
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 _scaleFactor = 3.0;
|
||||||
double _baseScaleFactor = 3.0;
|
double _baseScaleFactor = 3.0;
|
||||||
int? _scaleRestoreAssetIndex;
|
int? _scaleRestoreAssetIndex;
|
||||||
|
_TimelineRowAnchor? _pendingRestoreRowAnchor;
|
||||||
|
bool _hasPendingRowAnchorRestore = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController = ScrollController(
|
_scrollController = ScrollController(
|
||||||
initialScrollOffset: widget.initialScrollOffset ?? 0.0,
|
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);
|
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||||
|
|
||||||
|
|
@ -142,6 +188,75 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
_baseScaleFactor = _scaleFactor;
|
_baseScaleFactor = _scaleFactor;
|
||||||
|
|
||||||
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
|
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) {
|
void _onEvent(Event event) {
|
||||||
|
|
@ -187,6 +302,45 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
_scaleRestoreAssetIndex = null;
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
|
@ -331,6 +485,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
||||||
|
|
||||||
final grid = CustomScrollView(
|
final grid = CustomScrollView(
|
||||||
|
key: const PageStorageKey<String>('timeline-grid-scroll'),
|
||||||
primary: true,
|
primary: true,
|
||||||
physics: _scrollPhysics,
|
physics: _scrollPhysics,
|
||||||
cacheExtent: maxHeight * 2,
|
cacheExtent: maxHeight * 2,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||||
|
|
@ -23,26 +22,39 @@ class MemoryLane extends HookConsumerWidget {
|
||||||
(memories) => memories != null
|
(memories) => memories != null
|
||||||
? ConstrainedBox(
|
? ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
child: CarouselView(
|
child: ListView.builder(
|
||||||
itemExtent: 145.0,
|
key: const PageStorageKey<String>('memory-lane-scroll'),
|
||||||
shrinkExtent: 1.0,
|
scrollDirection: Axis.horizontal,
|
||||||
elevation: 2,
|
physics: const BouncingScrollPhysics(),
|
||||||
backgroundColor: Colors.black,
|
itemCount: memories.length,
|
||||||
overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)),
|
itemBuilder: (ctx, memoryIndex) {
|
||||||
onTap: (memoryIndex) {
|
return Padding(
|
||||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
padding: const EdgeInsets.only(right: 8),
|
||||||
if (memories[memoryIndex].assets.isNotEmpty) {
|
child: InkWell(
|
||||||
final asset = memories[memoryIndex].assets[0];
|
onTap: () {
|
||||||
ref.read(currentAssetProvider.notifier).set(asset);
|
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||||
if (asset.isVideo || asset.isMotionPhoto) {
|
if (memories[memoryIndex].assets.isNotEmpty) {
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
final asset = memories[memoryIndex].assets[0];
|
||||||
}
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
}
|
if (asset.isVideo || asset.isMotionPhoto) {
|
||||||
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
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(),
|
: const SizedBox(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue