mirror of
https://github.com/immich-app/immich.git
synced 2026-06-27 17:03:22 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a4ddfd56 | |||
| ac74bca18b | |||
| e9d1951858 | |||
| 6e1143e799 |
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.0-rc.3",
|
||||
"url": "https://docs.v3.0.0-rc.3.archive.immich.app"
|
||||
"label": "v3.0.0-rc.4",
|
||||
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc3"
|
||||
version = "3.0.0rc4"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc3"
|
||||
version = "3.0.0rc4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3051,
|
||||
"android.injected.version.name" => "3.0.0-rc.3",
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3051,
|
||||
"android.injected.version.name" => "3.0.0-rc.3",
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
@@ -49,14 +49,21 @@ class BaseActionButton extends ConsumerWidget {
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final onPressed = this.onPressed;
|
||||
|
||||
return MenuItemButton(
|
||||
closeOnActivate: false,
|
||||
style: MenuItemButton.styleFrom(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
leadingIcon: Icon(iconData, color: iconColor, size: 20),
|
||||
onPressed: onPressed,
|
||||
onPressed: onPressed == null
|
||||
? null
|
||||
: () {
|
||||
onPressed();
|
||||
MenuController.maybeOf(context)?.close();
|
||||
},
|
||||
child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!_isVideoReady || widget.asset.isMotionPhoto || isCasting) Center(child: widget.image),
|
||||
if (!isCasting) ...[
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 3.0.0-rc.3
|
||||
- API version: 3.0.0-rc.4
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.0-rc.3+3051
|
||||
version: 3.0.0-rc.4+3052
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -16206,7 +16206,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 3.0.0-rc.3
|
||||
* 3.0.0-rc.4
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "3.0.0-rc.3",
|
||||
"version": "3.0.0-rc.4",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user