mirror of
https://github.com/immich-app/immich.git
synced 2026-06-27 17:03:22 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a4ddfd56 |
@@ -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<List<BaseAsset>> 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<void> preloadAssets(int index) => _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||
|
||||
|
||||
@@ -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<List<BaseAsset>> Function(int index, int count) getAssetsRange;
|
||||
final void Function(Set<BaseAsset> select, Set<BaseAsset> deselect) onChange;
|
||||
|
||||
final HashMap<int, BaseAsset> _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<int> _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<BaseAsset> select, Set<BaseAsset> 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 = <BaseAsset>{};
|
||||
final toDeselect = <BaseAsset>{};
|
||||
|
||||
_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<void> end() async {
|
||||
final lo = _lo;
|
||||
final hi = _hi;
|
||||
if (lo == null || hi == null) {
|
||||
return;
|
||||
}
|
||||
final missing = <int>[];
|
||||
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 = <BaseAsset>{};
|
||||
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<BaseAsset> 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<BaseAsset> toDeselect) {
|
||||
_pending.remove(index);
|
||||
final asset = _selected.remove(index);
|
||||
if (asset != null) {
|
||||
toDeselect.add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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 = <BaseAsset>{};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Segment> 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<BaseAsset> _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<Segment> 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<Segment> segments) => _assetIndexAtOffset(segments, _scrollController.offset);
|
||||
|
||||
int? _assetIndexAtOffset(List<Segment> 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
|
||||
|
||||
@@ -97,6 +97,15 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<BaseAsset> toSelect, Set<BaseAsset> toDeselect) {
|
||||
final selectedAssets = state.selectedAssets.toSet()
|
||||
..removeAll(toDeselect)
|
||||
..addAll(toSelect);
|
||||
state = state.copyWith(selectedAssets: selectedAssets);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const MultiSelectState(selectedAssets: {}, lockedSelectionAssets: {}, forceEnable: false);
|
||||
}
|
||||
|
||||
@@ -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 = <Segment>[
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<BaseAsset> all;
|
||||
late Set<int> inBuffer; // indices getAssetSafe resolves synchronously
|
||||
late List<Completer<List<BaseAsset>>> reads; // pending async reads, completed manually
|
||||
late List<({int from, int count})> readArgs;
|
||||
late Set<BaseAsset> selected;
|
||||
late DragSelectionController sut;
|
||||
|
||||
Set<BaseAsset> 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<void> 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<List<BaseAsset>>();
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<BaseAsset> 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<void> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user