mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
* feat: memories sliver * memories lane * display and show memory * fix: get correct memories * naming * pr feedback * use equalsValue for visibility --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
462 lines
16 KiB
Dart
462 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
import 'package:immich_mobile/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
|
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
|
|
|
class Timeline extends StatelessWidget {
|
|
const Timeline({super.key, this.topSliverWidget, this.topSliverWidgetHeight});
|
|
|
|
final Widget? topSliverWidget;
|
|
final double? topSliverWidgetHeight;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
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)),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
child: _SliverTimeline(
|
|
topSliverWidget: topSliverWidget,
|
|
topSliverWidgetHeight: topSliverWidgetHeight,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SliverTimeline extends ConsumerStatefulWidget {
|
|
const _SliverTimeline({this.topSliverWidget, this.topSliverWidgetHeight});
|
|
|
|
final Widget? topSliverWidget;
|
|
final double? topSliverWidgetHeight;
|
|
|
|
@override
|
|
ConsumerState createState() => _SliverTimelineState();
|
|
}
|
|
|
|
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|
final _scrollController = ScrollController();
|
|
StreamSubscription? _reloadSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_reloadSubscription =
|
|
EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
_reloadSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext _) {
|
|
final asyncSegments = ref.watch(timelineSegmentProvider);
|
|
final maxHeight =
|
|
ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
|
|
return asyncSegments.widgetWhen(
|
|
onData: (segments) {
|
|
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
|
final statusBarHeight = context.padding.top;
|
|
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
|
|
const scrubberBottomPadding = 100.0;
|
|
|
|
return PrimaryScrollController(
|
|
controller: _scrollController,
|
|
child: Stack(
|
|
children: [
|
|
Scrubber(
|
|
layoutSegments: segments,
|
|
timelineHeight: maxHeight,
|
|
topPadding: totalAppBarHeight + 10,
|
|
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
|
monthSegmentSnappingOffset: widget.topSliverWidgetHeight,
|
|
child: CustomScrollView(
|
|
primary: true,
|
|
cacheExtent: maxHeight * 2,
|
|
slivers: [
|
|
const ImmichSliverAppBar(
|
|
floating: true,
|
|
pinned: false,
|
|
snap: false,
|
|
),
|
|
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
|
_SliverSegmentedList(
|
|
segments: segments,
|
|
delegate: SliverChildBuilderDelegate(
|
|
(ctx, index) {
|
|
if (index >= childCount) return null;
|
|
final segment = segments.findByIndex(index);
|
|
return segment?.builder(ctx, index) ??
|
|
const SizedBox.shrink();
|
|
},
|
|
childCount: childCount,
|
|
addAutomaticKeepAlives: false,
|
|
// We add repaint boundary around tiles, so skip the auto boundaries
|
|
addRepaintBoundaries: false,
|
|
),
|
|
),
|
|
const SliverPadding(
|
|
padding: EdgeInsets.only(
|
|
bottom: scrubberBottomPadding,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Consumer(
|
|
builder: (_, consumerRef, child) {
|
|
final isMultiSelectEnabled = consumerRef.watch(
|
|
multiSelectProvider.select(
|
|
(s) => s.isEnabled,
|
|
),
|
|
);
|
|
|
|
if (isMultiSelectEnabled) {
|
|
return child!;
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
child: const Positioned(
|
|
top: 60,
|
|
left: 25,
|
|
child: _MultiSelectStatusButton(),
|
|
),
|
|
),
|
|
Consumer(
|
|
builder: (_, consumerRef, child) {
|
|
final isMultiSelectEnabled = consumerRef.watch(
|
|
multiSelectProvider.select(
|
|
(s) => s.isEnabled,
|
|
),
|
|
);
|
|
|
|
if (isMultiSelectEnabled) {
|
|
return child!;
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
child: const HomeBottomAppBar(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
|
final List<Segment> _segments;
|
|
|
|
const _SliverSegmentedList({
|
|
required List<Segment> segments,
|
|
required super.delegate,
|
|
}) : _segments = segments;
|
|
|
|
@override
|
|
_RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) =>
|
|
_RenderSliverTimelineBoxAdaptor(
|
|
childManager: context as SliverMultiBoxAdaptorElement,
|
|
segments: _segments,
|
|
);
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context,
|
|
_RenderSliverTimelineBoxAdaptor renderObject,
|
|
) {
|
|
renderObject.segments = _segments;
|
|
}
|
|
}
|
|
|
|
/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets
|
|
class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor {
|
|
List<Segment> _segments;
|
|
|
|
set segments(List<Segment> updatedSegments) {
|
|
if (_segments.equals(updatedSegments)) {
|
|
return;
|
|
}
|
|
_segments = updatedSegments;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
_RenderSliverTimelineBoxAdaptor({
|
|
required super.childManager,
|
|
required List<Segment> segments,
|
|
}) : _segments = segments;
|
|
|
|
int getMinChildIndexForScrollOffset(double offset) =>
|
|
_segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ??
|
|
0;
|
|
|
|
int getMaxChildIndexForScrollOffset(double offset) =>
|
|
_segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ??
|
|
0;
|
|
|
|
double indexToLayoutOffset(int index) =>
|
|
(_segments.findByIndex(index) ?? _segments.lastOrNull)
|
|
?.indexToLayoutOffset(index) ??
|
|
0;
|
|
|
|
double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
|
|
|
|
double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0;
|
|
|
|
@override
|
|
void performLayout() {
|
|
childManager.didStartLayout();
|
|
// Assume initially that we have enough children to fill the viewport/cache area.
|
|
childManager.setDidUnderflow(false);
|
|
|
|
final double scrollOffset =
|
|
constraints.scrollOffset + constraints.cacheOrigin;
|
|
assert(scrollOffset >= 0.0);
|
|
|
|
final double remainingExtent = constraints.remainingCacheExtent;
|
|
assert(remainingExtent >= 0.0);
|
|
|
|
final double targetScrollOffset = scrollOffset + remainingExtent;
|
|
|
|
// Find the index of the first child that should be visible or in the leading cache area.
|
|
final int firstRequiredChildIndex =
|
|
getMinChildIndexForScrollOffset(scrollOffset);
|
|
|
|
// Find the index of the last child that should be visible or in the trailing cache area.
|
|
final int? lastRequiredChildIndex = targetScrollOffset.isFinite
|
|
? getMaxChildIndexForScrollOffset(targetScrollOffset)
|
|
: null;
|
|
|
|
// Remove children that are no longer visible or within the cache area.
|
|
if (firstChild == null) {
|
|
collectGarbage(0, 0);
|
|
} else {
|
|
final int leadingChildrenToRemove =
|
|
calculateLeadingGarbage(firstIndex: firstRequiredChildIndex);
|
|
final int trailingChildrenToRemove = lastRequiredChildIndex == null
|
|
? 0
|
|
: calculateTrailingGarbage(lastIndex: lastRequiredChildIndex);
|
|
collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove);
|
|
}
|
|
|
|
// If there are currently no children laid out (e.g., initial load),
|
|
// try to add the first child needed for the current scroll offset.
|
|
if (firstChild == null) {
|
|
final double firstChildLayoutOffset =
|
|
indexToLayoutOffset(firstRequiredChildIndex);
|
|
final bool childAdded = addInitialChild(
|
|
index: firstRequiredChildIndex,
|
|
layoutOffset: firstChildLayoutOffset,
|
|
);
|
|
|
|
if (!childAdded) {
|
|
// There are either no children, or we are past the end of all our children.
|
|
final double max =
|
|
firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset();
|
|
geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max);
|
|
childManager.didFinishLayout();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Layout children that might have scrolled into view from the top (before the current firstChild).
|
|
RenderBox? highestLaidOutChild;
|
|
final childConstraints = constraints.asBoxConstraints();
|
|
|
|
for (int currentIndex = indexOf(firstChild!) - 1;
|
|
currentIndex >= firstRequiredChildIndex;
|
|
--currentIndex) {
|
|
final RenderBox? newLeadingChild =
|
|
insertAndLayoutLeadingChild(childConstraints);
|
|
if (newLeadingChild == null) {
|
|
// If a child is missing where we expect one, it indicates
|
|
// an inconsistency in offset that needs correction.
|
|
final Segment? segment =
|
|
_segments.findByIndex(currentIndex) ?? _segments.firstOrNull;
|
|
geometry = SliverGeometry(
|
|
// Request a scroll correction based on where the missing child should have been.
|
|
scrollOffsetCorrection:
|
|
segment?.indexToLayoutOffset(currentIndex) ?? 0.0,
|
|
);
|
|
// Parent will re-layout everything.
|
|
return;
|
|
}
|
|
final childParentData =
|
|
newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData;
|
|
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
|
|
assert(childParentData.index == currentIndex);
|
|
highestLaidOutChild ??= newLeadingChild;
|
|
}
|
|
|
|
// If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]),
|
|
// or even if it did, we need to ensure the first visible child is correctly laid out
|
|
// and establish our starting point for laying out trailing children.
|
|
|
|
// If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children.
|
|
// The [firstChild] that existed at the start of performLayout is still the first one we need.
|
|
if (highestLaidOutChild == null) {
|
|
firstChild!.layout(childConstraints);
|
|
final childParentData =
|
|
firstChild!.parentData! as SliverMultiBoxAdaptorParentData;
|
|
childParentData.layoutOffset =
|
|
indexToLayoutOffset(firstRequiredChildIndex);
|
|
highestLaidOutChild = firstChild;
|
|
}
|
|
|
|
RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild;
|
|
|
|
// Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children
|
|
// until we reach the [lastRequiredChildIndex] or run out of children.
|
|
double calculatedMaxScrollOffset = double.infinity;
|
|
|
|
for (int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1;
|
|
lastRequiredChildIndex == null ||
|
|
currentIndex <= lastRequiredChildIndex;
|
|
++currentIndex) {
|
|
RenderBox? child = childAfter(mostRecentlyLaidOutChild!);
|
|
|
|
if (child == null || indexOf(child) != currentIndex) {
|
|
child = insertAndLayoutChild(
|
|
childConstraints,
|
|
after: mostRecentlyLaidOutChild,
|
|
);
|
|
if (child == null) {
|
|
final Segment? segment =
|
|
_segments.findByIndex(currentIndex) ?? _segments.lastOrNull;
|
|
calculatedMaxScrollOffset =
|
|
segment?.indexToLayoutOffset(currentIndex) ??
|
|
computeMaxScrollOffset();
|
|
break;
|
|
}
|
|
} else {
|
|
child.layout(childConstraints);
|
|
}
|
|
|
|
mostRecentlyLaidOutChild = child;
|
|
final childParentData = mostRecentlyLaidOutChild.parentData!
|
|
as SliverMultiBoxAdaptorParentData;
|
|
assert(childParentData.index == currentIndex);
|
|
childParentData.layoutOffset = indexToLayoutOffset(currentIndex);
|
|
}
|
|
|
|
final int lastLaidOutChildIndex = indexOf(lastChild!);
|
|
final double leadingScrollOffset =
|
|
indexToLayoutOffset(firstRequiredChildIndex);
|
|
final double trailingScrollOffset =
|
|
indexToLayoutOffset(lastLaidOutChildIndex + 1);
|
|
|
|
assert(
|
|
firstRequiredChildIndex == 0 ||
|
|
(childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <=
|
|
precisionErrorTolerance,
|
|
);
|
|
assert(debugAssertChildListIsNonEmptyAndContiguous());
|
|
assert(indexOf(firstChild!) == firstRequiredChildIndex);
|
|
assert(
|
|
lastRequiredChildIndex == null ||
|
|
lastLaidOutChildIndex <= lastRequiredChildIndex,
|
|
);
|
|
|
|
calculatedMaxScrollOffset = math.min(
|
|
calculatedMaxScrollOffset,
|
|
estimateMaxScrollOffset(),
|
|
);
|
|
|
|
final double paintExtent = calculatePaintOffset(
|
|
constraints,
|
|
from: leadingScrollOffset,
|
|
to: trailingScrollOffset,
|
|
);
|
|
|
|
final double cacheExtent = calculateCacheOffset(
|
|
constraints,
|
|
from: leadingScrollOffset,
|
|
to: trailingScrollOffset,
|
|
);
|
|
|
|
final double targetEndScrollOffsetForPaint =
|
|
constraints.scrollOffset + constraints.remainingPaintExtent;
|
|
final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite
|
|
? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint)
|
|
: null;
|
|
|
|
final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset);
|
|
|
|
geometry = SliverGeometry(
|
|
scrollExtent: calculatedMaxScrollOffset,
|
|
paintExtent: paintExtent,
|
|
maxPaintExtent: maxPaintExtent,
|
|
// Indicates if there's content scrolled off-screen.
|
|
// This is true if the last child needed for painting is actually laid out,
|
|
// or if the first child is partially visible.
|
|
hasVisualOverflow: (targetLastIndexForPaint != null &&
|
|
lastLaidOutChildIndex >= targetLastIndexForPaint) ||
|
|
constraints.scrollOffset > 0.0,
|
|
cacheExtent: cacheExtent,
|
|
);
|
|
|
|
// We may have started the layout while scrolled to the end, which would not
|
|
// expose a new child.
|
|
if (calculatedMaxScrollOffset == trailingScrollOffset) {
|
|
childManager.setDidUnderflow(true);
|
|
}
|
|
|
|
childManager.didFinishLayout();
|
|
}
|
|
}
|
|
|
|
class _MultiSelectStatusButton extends ConsumerWidget {
|
|
const _MultiSelectStatusButton();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final selectCount =
|
|
ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
|
|
return ElevatedButton.icon(
|
|
onPressed: () => ref.read(multiSelectProvider.notifier).reset(),
|
|
icon: Icon(
|
|
Icons.close_rounded,
|
|
color: context.colorScheme.onPrimary,
|
|
),
|
|
label: Text(
|
|
selectCount.toString(),
|
|
style: context.textTheme.titleMedium?.copyWith(
|
|
height: 2.5,
|
|
color: context.colorScheme.onPrimary,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|