chore(mobile): split Timeline widget into smaller parts (#31223)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class MultiSelectStatusButton extends ConsumerWidget {
|
||||
const MultiSelectStatusButton({super.key});
|
||||
|
||||
@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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||
|
||||
class SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<Segment> _segments;
|
||||
|
||||
const SliverSegmentedList({super.key, required this._segments, required super.delegate});
|
||||
|
||||
@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 this._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();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -18,10 +16,13 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/multi_select_status_button.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/sliver_segmented_list.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline_pinch_zoom.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -153,9 +154,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
final Set<BaseAsset> _draggedAssets = HashSet();
|
||||
ScrollPhysics? _scrollPhysics;
|
||||
|
||||
int _perRow = 4;
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
int? _restoreAssetIndex;
|
||||
|
||||
final Debouncer _fastScrollDebouncer = Debouncer(interval: const Duration(milliseconds: 100));
|
||||
@@ -167,11 +165,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
|
||||
_eventSubscription = EventStream.shared.listen(_onEvent);
|
||||
|
||||
final currentTilesPerRow = ref.read(appConfigProvider.select((config) => config.timeline.tilesPerRow));
|
||||
_perRow = currentTilesPerRow;
|
||||
_scaleFactor = 7.0 - _perRow;
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
|
||||
ref.listenManual(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
|
||||
}
|
||||
|
||||
@@ -458,7 +451,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
slivers: [
|
||||
if (isSelectionMode) const SelectionSliverAppBar() else if (widget.appBar != null) widget.appBar!,
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
SliverSegmentedList(
|
||||
segments: segments,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
@@ -495,32 +488,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
timeline = grid;
|
||||
}
|
||||
|
||||
return RawGestureDetector(
|
||||
gestures: {
|
||||
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
|
||||
() => CustomScaleGestureRecognizer(),
|
||||
(CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
|
||||
unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, _perRow));
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
return TimelinePinchZoom(
|
||||
onColumnCountWillChange: () {
|
||||
final targetAssetIndex = _getCurrentAssetIndex(segments);
|
||||
setState(() {
|
||||
_restoreAssetIndex = targetAssetIndex;
|
||||
});
|
||||
},
|
||||
child: TimelineDragRegion(
|
||||
onStart: !isReadonlyModeEnabled ? _setDragStartIndex : null,
|
||||
@@ -544,7 +517,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
left: 25,
|
||||
child: const SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: Center(child: _MultiSelectStatusButton()),
|
||||
child: Center(child: MultiSelectStatusButton()),
|
||||
),
|
||||
),
|
||||
if (isBottomWidgetVisible) widget.bottomSheet!,
|
||||
@@ -560,232 +533,3 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget {
|
||||
final List<Segment> _segments;
|
||||
|
||||
const _SliverSegmentedList({required this._segments, required super.delegate});
|
||||
|
||||
@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 this._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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// accepts a gesture even though it should reject it (because child won)
|
||||
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
acceptGesture(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
|
||||
/// Mapping of pinch to zoom on the timeline grid to the number of columns displayed. Directly drives the `timelineTilesPerRow` setting
|
||||
class TimelinePinchZoom extends ConsumerStatefulWidget {
|
||||
const TimelinePinchZoom({super.key, required this.onColumnCountWillChange, required this.child});
|
||||
|
||||
/// Callback when a column count change is expected
|
||||
///
|
||||
/// This is called immediately before the new count is written to the setting, while the gesture is still actively resolving
|
||||
final VoidCallback onColumnCountWillChange;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _TimelinePinchZoomState();
|
||||
}
|
||||
|
||||
class _TimelinePinchZoomState extends ConsumerState<TimelinePinchZoom> {
|
||||
int _perRow = 4;
|
||||
double _scaleFactor = 3.0;
|
||||
double _baseScaleFactor = 3.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_perRow = ref.read(appConfigProvider.select((config) => config.timeline.tilesPerRow));
|
||||
_scaleFactor = 7.0 - _perRow;
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
gestures: {
|
||||
_CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomScaleGestureRecognizer>(
|
||||
() => _CustomScaleGestureRecognizer(),
|
||||
(_CustomScaleGestureRecognizer scale) {
|
||||
scale.onStart = (details) {
|
||||
_baseScaleFactor = _scaleFactor;
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * details.scale), 1.0);
|
||||
final newPerRow = 7 - newScaleFactor.toInt();
|
||||
|
||||
if (newPerRow != _perRow) {
|
||||
widget.onColumnCountWillChange();
|
||||
setState(() {
|
||||
_scaleFactor = newScaleFactor;
|
||||
_perRow = newPerRow;
|
||||
});
|
||||
|
||||
unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, _perRow));
|
||||
}
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// accepts a gesture even though it should reject it (because child won)
|
||||
class _CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
acceptGesture(pointer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user