From b4a4ddfd56a199862fd3a8fccee60d45e2b09208 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sun, 28 Jun 2026 00:46:08 +0600 Subject: [PATCH] fix(mobile): keep drag select going while the grid auto scrolls --- .../lib/domain/services/timeline.service.dart | 12 ++ .../timeline/drag_selection_controller.dart | 175 ++++++++++++++++++ .../widgets/timeline/timeline.widget.dart | 128 +++++++------ .../timeline/multiselect.provider.dart | 9 + .../timeline/asset_index_at_offset_test.dart | 60 ++++++ .../drag_selection_controller_test.dart | 157 ++++++++++++++++ .../unit/services/timeline_service_test.dart | 100 ++++++++++ 7 files changed, 587 insertions(+), 54 deletions(-) create mode 100644 mobile/lib/presentation/widgets/timeline/drag_selection_controller.dart create mode 100644 mobile/test/unit/presentation/widgets/timeline/asset_index_at_offset_test.dart create mode 100644 mobile/test/unit/presentation/widgets/timeline/drag_selection_controller_test.dart create mode 100644 mobile/test/unit/services/timeline_service_test.dart diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 4cc58b0fe7..c398f29b03 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -182,6 +182,18 @@ class TimelineService { return _buffer.slice(start, start + count); } + /// Reads a range without disturbing the buffer; queries the source if it isn't resident. + Future> getAssetsRange(int index, int count) async { + if (index < 0 || count <= 0 || index >= _totalAssets) { + return const []; + } + final clamped = math.min(count, _totalAssets - index); + if (hasRange(index, clamped)) { + return getAssets(index, clamped); + } + return _assetSource(index, clamped); + } + // Preload assets around the given index for asset viewer Future preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); diff --git a/mobile/lib/presentation/widgets/timeline/drag_selection_controller.dart b/mobile/lib/presentation/widgets/timeline/drag_selection_controller.dart new file mode 100644 index 0000000000..4b60d5cbe1 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/drag_selection_controller.dart @@ -0,0 +1,175 @@ +import 'dart:collection'; + +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +// Tracks the [anchor..current] range selected by a drag. The in-buffer part of +// each tick is selected synchronously so it follows the finger without racing; +// the rare beyond-buffer part is read async (applied only if still in range) and +// [end] reads whatever is still missing so the final range always lands. +class DragSelectionController { + DragSelectionController({required this.getAssetSafe, required this.getAssetsRange, required this.onChange}); + + final BaseAsset? Function(int index) getAssetSafe; + final Future> Function(int index, int count) getAssetsRange; + final void Function(Set select, Set deselect) onChange; + + final HashMap _selected = HashMap(); + // Indices the buffer didn't hold yet (the edge outran the async buffer-load on a + // fast scroll); read in by _extendPending and guaranteed by end(). + final Set _pending = {}; + int? _anchor; + int? _lo; + int? _hi; + bool _disposed = false; + + // Call before starting a new drag so a previous drag's in-flight read can't + // leak into the new selection. + void dispose() => _disposed = true; + + void _emit(Set select, Set deselect) { + if (_disposed) { + return; + } + onChange(select, deselect); + } + + void start(int anchor) { + _selected.clear(); + _pending.clear(); + _anchor = anchor; + _lo = anchor; + _hi = anchor; + _select(anchor); + _extendPending(); + } + + void enter(int current) { + final anchor = _anchor; + if (anchor == null || _lo == null || _hi == null) { + return; + } + final ns = current < anchor ? current : anchor; + final ne = current < anchor ? anchor : current; + final ps = _lo!; + final pe = _hi!; + if (ns == ps && ne == pe) { + return; + } + + final toSelect = {}; + final toDeselect = {}; + + _forEach(ps, ns - 1, (k) => _removeIndex(k, toDeselect)); + _forEach(ne + 1, pe, (k) => _removeIndex(k, toDeselect)); + _forEach(ns, ps - 1, (k) => _addIndex(k, toSelect)); + _forEach(pe + 1, ne, (k) => _addIndex(k, toSelect)); + + _lo = ns; + _hi = ne; + + if (toSelect.isNotEmpty || toDeselect.isNotEmpty) { + _emit(toSelect, toDeselect); + } + _extendPending(); + } + + Future end() async { + final lo = _lo; + final hi = _hi; + if (lo == null || hi == null) { + return; + } + final missing = []; + for (var k = lo; k <= hi; k++) { + if (!_selected.containsKey(k)) { + missing.add(k); + } + } + if (missing.isEmpty) { + return; + } + final from = missing.first; + final assets = await getAssetsRange(from, missing.last - from + 1); + final missingSet = missing.toSet(); + final toSelect = {}; + for (var i = 0; i < assets.length; i++) { + final idx = from + i; + if (missingSet.contains(idx) && !_selected.containsKey(idx)) { + _selected[idx] = assets[i]; + _pending.remove(idx); + toSelect.add(assets[i]); + } + } + if (toSelect.isNotEmpty) { + _emit(toSelect, const {}); + } + } + + void _select(int index) { + final asset = getAssetSafe(index); + if (asset != null) { + _selected[index] = asset; + _pending.remove(index); + _emit({asset}, const {}); + } else { + _pending.add(index); + } + } + + void _addIndex(int index, Set toSelect) { + if (_selected.containsKey(index)) { + return; + } + final asset = getAssetSafe(index); + if (asset != null) { + _selected[index] = asset; + _pending.remove(index); + toSelect.add(asset); + } else { + _pending.add(index); + } + } + + void _removeIndex(int index, Set toDeselect) { + _pending.remove(index); + final asset = _selected.remove(index); + if (asset != null) { + toDeselect.add(asset); + } + } + + Future _extendPending() async { + if (_pending.isEmpty) { + return; + } + var from = _pending.first; + var to = _pending.first; + for (final k in _pending) { + if (k < from) { + from = k; + } + if (k > to) { + to = k; + } + } + final assets = await getAssetsRange(from, to - from + 1); + final toSelect = {}; + for (var i = 0; i < assets.length; i++) { + final idx = from + i; + if (_pending.contains(idx) && _lo != null && idx >= _lo! && idx <= _hi!) { + _selected[idx] = assets[i]; + toSelect.add(assets[i]); + } + } + _pending.removeWhere((idx) => _selected.containsKey(idx)); + if (toSelect.isNotEmpty) { + _emit(toSelect, const {}); + } + } + + void _forEach(int lo, int hi, void Function(int) fn) { + for (var k = lo; k <= hi; k++) { + fn(k); + } + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 8acaebf40e..f2b1e0be0a 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:collection'; import 'dart:math' as math; import 'package:collection/collection.dart'; @@ -8,7 +7,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; @@ -20,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.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/presentation/widgets/timeline/drag_selection_controller.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; @@ -29,6 +28,27 @@ 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/selection_sliver_app_bar.dart'; +// First asset index of the row shown at [offset]. Pure for testing. +@visibleForTesting +int? assetIndexAtOffset( + List segments, + double offset, { + required int columnCount, + required double maxScrollExtent, +}) { + final clamped = offset.clamp(0.0, maxScrollExtent); + final segment = segments.findByOffset(clamped) ?? segments.lastOrNull; + if (segment == null) { + return null; + } + final rowIndex = segment.getMinChildIndexForScrollOffset(clamped); + if (rowIndex > segment.firstIndex) { + final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); + return segment.firstAssetIndex + rowIndexInSegment * columnCount; + } + return segment.firstAssetIndex; +} + class Timeline extends StatelessWidget { const Timeline({ super.key, @@ -140,9 +160,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { StreamSubscription? _eventSubscription; // Drag selection state + static const _autoScrollStep = 175.0; + static const _autoScrollDuration = Duration(milliseconds: 125); bool _dragging = false; - TimelineAssetIndex? _dragAnchorIndex; - final Set _draggedAssets = HashSet(); + DragSelectionController? _dragController; ScrollPhysics? _scrollPhysics; int _perRow = 4; @@ -226,26 +247,18 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { EventStream.shared.emit(MultiSelectToggleEvent(isEnabled)); } - int? _getCurrentAssetIndex(List segments) { - final currentOffset = _scrollController.offset.clamp(0.0, _scrollController.position.maxScrollExtent); - final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull; - int? targetAssetIndex; - if (segment != null) { - final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset); - if (rowIndex > segment.firstIndex) { - final rowIndexInSegment = rowIndex - (segment.firstIndex + 1); - final assetsPerRow = ref.read(timelineArgsProvider).columnCount; - final assetIndexInSegment = rowIndexInSegment * assetsPerRow; - targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment; - } else { - targetAssetIndex = segment.firstAssetIndex; - } - } - return targetAssetIndex; - } + int? _getCurrentAssetIndex(List segments) => _assetIndexAtOffset(segments, _scrollController.offset); + + int? _assetIndexAtOffset(List segments, double offset) => assetIndexAtOffset( + segments, + offset, + columnCount: ref.read(timelineArgsProvider).columnCount, + maxScrollExtent: _scrollController.position.maxScrollExtent, + ); @override void dispose() { + _dragController?.dispose(); _scrollController.dispose(); _eventSubscription?.cancel(); super.dispose(); @@ -295,9 +308,21 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { // Drag selection methods void _setDragStartIndex(TimelineAssetIndex index) { + // Stop the old drag's controller so its in-flight read can't leak into this one. + _dragController?.dispose(); + final timelineService = ref.read(timelineServiceProvider); + _dragController = DragSelectionController( + getAssetSafe: timelineService.getAssetSafe, + getAssetsRange: timelineService.getAssetsRange, + onChange: (select, deselect) { + if (!mounted) { + return; + } + ref.read(multiSelectProvider.notifier).selectRange(select, deselect); + }, + )..start(index.assetIndex); setState(() { _scrollPhysics = const ClampingScrollPhysics(); - _dragAnchorIndex = index; _dragging = true; }); } @@ -313,8 +338,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { }); setState(() { _dragging = false; - _draggedAssets.clear(); }); + // Apply the full final range even if a read is still in flight on lift. + final finishing = _dragController?.end(); + if (finishing != null) { + unawaited(finishing); + } final timelineState = ref.read(timelineStateProvider.notifier); Future.delayed(const Duration(milliseconds: 300), () { timelineState.setScrolling(false); @@ -322,42 +351,33 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { } void _dragScroll(ScrollDirection direction) { - _scrollController.animateTo( - _scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175), - duration: const Duration(milliseconds: 125), - curve: Curves.easeOut, - ); + final position = _scrollController.position; + final step = direction == ScrollDirection.forward ? _autoScrollStep : -_autoScrollStep; + final target = (_scrollController.offset + step).clamp(0.0, position.maxScrollExtent); + _scrollController.animateTo(target, duration: _autoScrollDuration, curve: Curves.easeOut); + + // A held finger emits no move events, so extend the selection to the asset + // at the leading edge of the scroll instead. + final controller = _dragController; + if (controller == null) { + return; + } + final segments = ref.read(timelineSegmentProvider).valueOrNull; + if (segments == null) { + return; + } + final edgeOffset = direction == ScrollDirection.forward ? target + position.viewportDimension : target; + final edgeIndex = _assetIndexAtOffset(segments, edgeOffset); + if (edgeIndex != null) { + controller.enter(edgeIndex); + } } void _handleDragAssetEnter(TimelineAssetIndex index) { - if (_dragAnchorIndex == null || !_dragging) { + if (!_dragging) { return; } - - final timelineService = ref.read(timelineServiceProvider); - final dragAnchorIndex = _dragAnchorIndex!; - - // Calculate the range of assets to select - final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex); - final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex); - final count = endIndex - startIndex + 1; - - // Load the assets in the range - if (timelineService.hasRange(startIndex, count)) { - final selectedAssets = timelineService.getAssets(startIndex, count); - - // Clear previous drag selection and add new range - final multiSelectNotifier = ref.read(multiSelectProvider.notifier); - for (final asset in _draggedAssets) { - multiSelectNotifier.deselectAsset(asset); - } - _draggedAssets.clear(); - - for (final asset in selectedAssets) { - multiSelectNotifier.selectAsset(asset); - _draggedAssets.add(asset); - } - } + _dragController?.enter(index.assetIndex); } @override diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 10c8bb86b6..82cc1d6018 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -97,6 +97,15 @@ class MultiSelectNotifier extends Notifier { } } + // Drops the previous drag range and adds the new one in a single update. The + // full-set copy per drag tick is the accepted cost of immutable state. + void selectRange(Set toSelect, Set toDeselect) { + final selectedAssets = state.selectedAssets.toSet() + ..removeAll(toDeselect) + ..addAll(toSelect); + state = state.copyWith(selectedAssets: selectedAssets); + } + void reset() { state = const MultiSelectState(selectedAssets: {}, lockedSelectionAssets: {}, forceEnable: false); } diff --git a/mobile/test/unit/presentation/widgets/timeline/asset_index_at_offset_test.dart b/mobile/test/unit/presentation/widgets/timeline/asset_index_at_offset_test.dart new file mode 100644 index 0000000000..213c74d8ef --- /dev/null +++ b/mobile/test/unit/presentation/widgets/timeline/asset_index_at_offset_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; + +void main() { + // Two day-segments, 4 columns, 8 assets each (2 rows). header 50, tile 100, no spacing. + // A: header@[0,50) rows@50,150 assets 0..7 offset [0,250] + // B: header@[250,300) rows@300,400 assets 8..15 offset [250,500] + const columnCount = 4; + const maxScrollExtent = 500.0; + final segments = [ + const FixedSegment( + firstIndex: 0, + lastIndex: 2, + startOffset: 0, + endOffset: 250, + firstAssetIndex: 0, + bucket: Bucket(assetCount: 8), + tileHeight: 100, + columnCount: columnCount, + headerExtent: 50, + spacing: 0, + header: HeaderType.day, + ), + const FixedSegment( + firstIndex: 3, + lastIndex: 5, + startOffset: 250, + endOffset: 500, + firstAssetIndex: 8, + bucket: Bucket(assetCount: 8), + tileHeight: 100, + columnCount: columnCount, + headerExtent: 50, + spacing: 0, + header: HeaderType.day, + ), + ]; + + int? at(double offset) => + assetIndexAtOffset(segments, offset, columnCount: columnCount, maxScrollExtent: maxScrollExtent); + + test('maps an offset to the first asset of the row shown there', () { + expect(at(0), 0); // top of segment A + expect(at(150), 4); // second row of A + expect(at(350), 8); // first row of B + expect(at(450), 12); // second row of B + }); + + test('clamps offsets outside the scroll range', () { + expect(at(-100), 0); // below the top -> first asset + expect(at(9999), at(maxScrollExtent)); // past the end -> same as the max offset + }); + + test('returns null for empty segments', () { + expect(assetIndexAtOffset(const [], 100, columnCount: columnCount, maxScrollExtent: maxScrollExtent), isNull); + }); +} diff --git a/mobile/test/unit/presentation/widgets/timeline/drag_selection_controller_test.dart b/mobile/test/unit/presentation/widgets/timeline/drag_selection_controller_test.dart new file mode 100644 index 0000000000..9f0dddc86d --- /dev/null +++ b/mobile/test/unit/presentation/widgets/timeline/drag_selection_controller_test.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/drag_selection_controller.dart'; + +import '../../../factories/remote_asset_factory.dart'; + +void main() { + const total = 50; + late List all; + late Set inBuffer; // indices getAssetSafe resolves synchronously + late List>> reads; // pending async reads, completed manually + late List<({int from, int count})> readArgs; + late Set selected; + late DragSelectionController sut; + + Set range(int lo, int hi) => {for (var i = lo; i <= hi; i++) all[i]}; + + void completeRead(int i) { + final a = readArgs[i]; + reads[i].complete([for (var k = a.from; k < a.from + a.count && k < total; k++) all[k]]); + } + + Future settle() => Future(() {}); + + setUp(() { + all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(3, '0')}')); + inBuffer = {for (var i = 0; i < total; i++) i}; // default: everything buffered (sync) + reads = []; + readArgs = []; + selected = {}; + sut = DragSelectionController( + getAssetSafe: (i) => inBuffer.contains(i) ? all[i] : null, + getAssetsRange: (from, count) { + final c = Completer>(); + reads.add(c); + readArgs.add((from: from, count: count)); + return c.future; + }, + onChange: (select, deselect) { + selected + ..addAll(select) + ..removeAll(deselect); + }, + ); + }); + + group('live selection (all in buffer, synchronous)', () { + test('dragging down selects the whole range as it grows', () { + sut.start(2); + sut.enter(6); + sut.enter(12); + expect(selected, range(2, 12)); + expect(reads, isEmpty, reason: 'everything buffered -> no async read'); + }); + + test('dragging back up toward the anchor deselects the shrunk tail', () { + sut.start(2); + sut.enter(12); + expect(selected, range(2, 12)); + sut.enter(6); // reverse + expect(selected, range(2, 6)); + sut.enter(3); // reverse more + expect(selected, range(2, 3)); + }); + + test('dragging past the anchor flips the range', () { + sut.start(10); + sut.enter(14); + expect(selected, range(10, 14)); + sut.enter(7); // crosses the anchor + expect(selected, range(7, 10)); + }); + }); + + group('beyond-buffer (async)', () { + setUp(() => inBuffer = {}); // nothing buffered -> every tile needs an async read + + test('drag-end fills the full range even if every live read is still in flight', () async { + sut.start(0); + sut.enter(20); + // simulate the real-rate race: none of the in-drag reads have completed + expect(selected, isEmpty); + + final ending = sut.end(); + // end() issues its own read for the missing range; complete it + completeRead(reads.length - 1); + await ending; + + expect(selected, range(0, 20), reason: 'final range must always apply on drag-end'); + }); + + test('out-of-order live read completions never corrupt the selection', () async { + sut.start(0); // issues read for [0,1] + sut.enter(10); // issues read for [0,11] + sut.enter(20); // issues read for [0,21] + expect(reads.length, 3); + + // complete newest first, then older ones (out of order) + completeRead(2); + await settle(); + completeRead(1); + await settle(); + completeRead(0); + await settle(); + + expect(selected, range(0, 20)); + + final ending = sut.end(); + await ending; // nothing missing -> no extra read + expect(selected, range(0, 20)); + }); + + test('a disposed controller never emits when its in-flight read resolves', () async { + sut.start(0); // read [0,1] (pending, in flight) + sut.enter(20); // read [0,21] (pending, in flight) + expect(selected, isEmpty); + + // a new drag starts -> the old controller is disposed + final ending = sut.end(); // issues end()'s fill read + sut.dispose(); + + // every in-flight read for the old controller now resolves + for (var i = 0; i < reads.length; i++) { + if (!reads[i].isCompleted) { + completeRead(i); + } + } + await ending; + await settle(); + + expect(selected, isEmpty, reason: 'a disposed controller must not leak into the new selection'); + }); + + test('a late read for tiles dragged back out of range is ignored', () async { + sut.start(0); // read [0,1] + sut.enter(20); // read [0,21] + sut.enter(5); // shrink back; read [0,6] + // complete the stale wide read AFTER the shrink + completeRead(1); // [0,21] + await settle(); + // indices 6..20 left the range -> must not be selected + expect(selected.intersection(range(6, 20)), isEmpty); + + final ending = sut.end(); + // complete any read end() issued for the (now smaller) missing range + for (var i = 0; i < reads.length; i++) { + if (!reads[i].isCompleted) { + completeRead(i); + } + } + await ending; + expect(selected, range(0, 5)); + }); + }); +} diff --git a/mobile/test/unit/services/timeline_service_test.dart b/mobile/test/unit/services/timeline_service_test.dart new file mode 100644 index 0000000000..b98373f6ba --- /dev/null +++ b/mobile/test/unit/services/timeline_service_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; + +import '../factories/remote_asset_factory.dart'; + +void main() { + // total must exceed the sliding buffer so it cannot hold the whole library at once + const total = 2000; + late List all; + late TimelineService sut; + + TimelineService buildService() { + all = List.generate(total, (i) => RemoteAssetFactory.create(id: 'a${i.toString().padLeft(5, '0')}')); + return TimelineService(( + assetSource: (index, count) async { + final end = (index + count) > total ? total : index + count; + return all.sublist(index, end); + }, + bucketSource: () => Stream.value([const Bucket(assetCount: total)]), + origin: TimelineOrigin.main, + )); + } + + Future settle() => Future.delayed(const Duration(milliseconds: 10)); + + setUp(() async { + sut = buildService(); + await settle(); // let the bucket subscription load the first batch and set totalAssets + }); + + tearDown(() async { + await sut.dispose(); + }); + + test('buffer holds the first batch but not the whole library', () { + expect(sut.totalAssets, total); + expect(sut.hasRange(0, kTimelineAssetLoadBatchSize), isTrue); + expect(sut.hasRange(0, total), isFalse); + }); + + // #27118 / #20855 mechanism: drag-selecting from a low anchor while the grid + // auto-scrolls down slides the buffer forward to follow the finger. once the + // buffer offset passes the anchor, hasRange(anchor, ...) is false and + // _handleDragAssetEnter silently stops extending the selection. + test('anchor drops out of the buffer after the grid scrolls down', () async { + const anchor = 5; + expect(sut.hasRange(anchor, 100), isTrue); + + // the grid loads a far-down range as it auto-scrolls during the drag + await sut.loadAssets(1500, 1); + + const current = 1500; + const count = current - anchor + 1; + expect(sut.hasRange(anchor, 1), isFalse, reason: 'anchor is now below the buffer offset'); + expect(sut.hasRange(anchor, count), isFalse, reason: 'the full drag range is no longer resident'); + expect(() => sut.getAssets(anchor, count), throwsRangeError); + }); + + // the fix: getAssetsRange returns the whole drag range regardless of the + // buffer position, so the selection keeps extending while scrolling. + group('getAssetsRange', () { + test('returns a buffered range', () async { + final assets = await sut.getAssetsRange(0, 50); + expect(assets.length, 50); + expect(assets.first, all[0]); + expect(assets.last, all[49]); + }); + + test('returns a range wider than the buffer', () async { + final assets = await sut.getAssetsRange(0, total); + expect(assets.length, total); + expect(assets.first, all[0]); + expect(assets.last, all[total - 1]); + }); + + test('returns the anchor range after the buffer scrolled past the anchor', () async { + const anchor = 5; + await sut.loadAssets(1500, 1); // buffer slides forward, dropping the anchor + expect(sut.hasRange(anchor, 1), isFalse); + + const current = 1500; + const count = current - anchor + 1; + final assets = await sut.getAssetsRange(anchor, count); + expect(assets.length, count); + expect(assets.first, all[anchor]); + expect(assets.last, all[current]); + }); + + test('clamps a range that runs past the end and ignores invalid input', () async { + final tail = await sut.getAssetsRange(total - 10, 100); + expect(tail.length, 10); + expect(await sut.getAssetsRange(-1, 10), isEmpty); + expect(await sut.getAssetsRange(0, 0), isEmpty); + expect(await sut.getAssetsRange(total, 10), isEmpty); + }); + }); +}