You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-08 23:07:06 +02:00
feat(mobile): sqlite timeline (#19197)
* wip: timeline * more segment extensions * added scrubber * refactor: timeline state * more refactors * fix scrubber segments * added remote thumb & thumbhash provider * feat: merged view * scrub / merged asset fixes * rename stuff & add tile indicators * fix local album timeline query * ignore hidden assets during sync * ignore recovered assets during sync * old scrubber * add video indicator * handle groupBy * handle partner inTimeline * show duration * reduce widget nesting in thumb tile * merge main * chore: extend cacheExtent * ignore touch events on scrub label when not visible * scrub label ignore events and hide immediately * auto reload on sync * refactor image providers * throttle db updates --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
365
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
365
mobile/lib/presentation/widgets/timeline/timeline.widget.dart
Normal file
@ -0,0 +1,365 @@
|
||||
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/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.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';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({super.key});
|
||||
|
||||
@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: const _SliverTimeline(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverTimeline extends StatefulWidget {
|
||||
const _SliverTimeline();
|
||||
|
||||
@override
|
||||
State createState() => _SliverTimelineState();
|
||||
}
|
||||
|
||||
class _SliverTimelineState extends State<_SliverTimeline> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
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;
|
||||
|
||||
return PrimaryScrollController(
|
||||
controller: _scrollController,
|
||||
child: Scrubber(
|
||||
layoutSegments: segments,
|
||||
timelineHeight: maxHeight,
|
||||
topPadding: context.padding.top + 10,
|
||||
bottomPadding: context.padding.bottom + 10,
|
||||
child: CustomScrollView(
|
||||
primary: true,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
_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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user