From df7494b49e378ecbb0c8c57310610da1cb857d20 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 8 Sep 2026 07:11:04 -0700 Subject: [PATCH] chore(mobile): split Timeline widget into smaller parts (#31223) --- .../multi_select_status_button.widget.dart | 21 ++ .../timeline/sliver_segmented_list.dart | 211 +++++++++++++ .../widgets/timeline/timeline.widget.dart | 278 +----------------- .../widgets/timeline/timeline_pinch_zoom.dart | 77 +++++ 4 files changed, 320 insertions(+), 267 deletions(-) create mode 100644 mobile/lib/presentation/widgets/timeline/multi_select_status_button.widget.dart create mode 100644 mobile/lib/presentation/widgets/timeline/sliver_segmented_list.dart create mode 100644 mobile/lib/presentation/widgets/timeline/timeline_pinch_zoom.dart diff --git a/mobile/lib/presentation/widgets/timeline/multi_select_status_button.widget.dart b/mobile/lib/presentation/widgets/timeline/multi_select_status_button.widget.dart new file mode 100644 index 000000000..50a7cd19a --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/multi_select_status_button.widget.dart @@ -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), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/sliver_segmented_list.dart b/mobile/lib/presentation/widgets/timeline/sliver_segmented_list.dart new file mode 100644 index 000000000..ea84005f3 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/sliver_segmented_list.dart @@ -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 _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 _segments; + + set segments(List 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(); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index d0ff5c60d..51fd10f56 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -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 _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 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 _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 _segments; - - set segments(List 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); - } -} diff --git a/mobile/lib/presentation/widgets/timeline/timeline_pinch_zoom.dart b/mobile/lib/presentation/widgets/timeline/timeline_pinch_zoom.dart new file mode 100644 index 000000000..8005abce4 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/timeline_pinch_zoom.dart @@ -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 { + 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); + } +}