mirror of
https://github.com/immich-app/immich.git
synced 2026-06-29 17:54:35 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7849816877 | |||
| 1ded04d29d | |||
| 29797dba9e | |||
| df383c1ead | |||
| af2efda310 | |||
| 5164feb5b4 | |||
| 56eb69b328 |
@@ -7,6 +7,7 @@ project(native_buffer LANGUAGES C)
|
||||
|
||||
add_library(native_buffer SHARED
|
||||
src/main/cpp/native_buffer.c
|
||||
src/main/cpp/native_image.c
|
||||
)
|
||||
|
||||
target_link_libraries(native_buffer jnigraphics)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <android/bitmap.h>
|
||||
|
||||
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
|
||||
#define TILE 32
|
||||
|
||||
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
|
||||
enum {
|
||||
ORIENTATION_FLIP_HORIZONTAL = 2,
|
||||
ORIENTATION_ROTATE_180 = 3,
|
||||
ORIENTATION_FLIP_VERTICAL = 4,
|
||||
ORIENTATION_TRANSPOSE = 5,
|
||||
ORIENTATION_ROTATE_90 = 6,
|
||||
ORIENTATION_TRANSVERSE = 7,
|
||||
ORIENTATION_ROTATE_270 = 8,
|
||||
};
|
||||
|
||||
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
|
||||
static int swaps_dims(int o) {
|
||||
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
|
||||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
|
||||
}
|
||||
|
||||
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
|
||||
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
|
||||
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
|
||||
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
|
||||
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
|
||||
switch (o) {
|
||||
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
|
||||
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
|
||||
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
|
||||
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
|
||||
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
|
||||
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
|
||||
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
|
||||
default: *base = 0; *stepX = 1; *stepY = dw; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
|
||||
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
|
||||
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
|
||||
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
|
||||
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
|
||||
for (int ty = 0; ty < sh; ty += TILE) {
|
||||
int yEnd = ty + TILE < sh ? ty + TILE : sh;
|
||||
for (int tx = 0; tx < sw; tx += TILE) {
|
||||
int xEnd = tx + TILE < sw ? tx + TILE : sw;
|
||||
for (int sy = ty; sy < yEnd; sy++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
|
||||
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
|
||||
for (int sx = tx; sx < xEnd; sx++) {
|
||||
dst[idx] = srcRow[sx];
|
||||
idx += stepX;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
|
||||
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_rotate(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sw = (int) info.width;
|
||||
int sh = (int) info.height;
|
||||
int dw = swaps_dims(orientation) ? sh : sw;
|
||||
int dh = swaps_dims(orientation) ? sw : sh;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int64_t base, stepX, stepY;
|
||||
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
|
||||
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {dw, dh, dw * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
|
||||
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
object NativeImage {
|
||||
init {
|
||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
||||
System.loadLibrary("native_buffer")
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
|
||||
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
|
||||
* non-8888 config) so the caller can fall back.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import android.provider.MediaStore.Images
|
||||
import android.provider.MediaStore.Video
|
||||
import android.util.Size
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import app.alextran.immich.NativeImage
|
||||
import kotlin.math.*
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
@@ -181,35 +183,88 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
val id = assetId.toLong()
|
||||
|
||||
signal.throwIfCanceled()
|
||||
val bitmap = if (isVideo) {
|
||||
decodeVideoThumbnail(id, size, signal)
|
||||
} else {
|
||||
decodeImage(id, size, signal)
|
||||
}
|
||||
|
||||
try {
|
||||
signal.throwIfCanceled()
|
||||
val res = bitmap.toNativeBuffer()
|
||||
signal.throwIfCanceled()
|
||||
val res = if (isVideo) {
|
||||
decodeVideoThumbnail(id, size, signal).toNativeBuffer()
|
||||
} else {
|
||||
val (bitmap, orientation) = decodeImage(id, size, signal)
|
||||
signal.throwIfCanceled()
|
||||
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||
bitmap.toNativeBuffer()
|
||||
} else {
|
||||
rotateToNativeBuffer(bitmap, orientation, signal)
|
||||
}
|
||||
}
|
||||
// Don't re-check cancellation here: res owns a malloc'd buffer, and bailing to CANCELLED would
|
||||
// orphan it. Deliver it; Dart frees the buffer itself if the request was cancelled meanwhile.
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
|
||||
// Returns the decoded bitmap plus the EXIF orientation that still needs applying. Only Q+ raw
|
||||
// decodes come back unrotated (ImageDecoder / loadThumbnail skip EXIF for raw like DNG); every
|
||||
// other path already orients itself, so it reports ORIENTATION_NORMAL.
|
||||
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Pair<Bitmap, Int> {
|
||||
signal.throwIfCanceled()
|
||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
val handleRaw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isRawMime(uri)
|
||||
val orientation = if (handleRaw) rawOrientation(uri) else ExifInterface.ORIENTATION_NORMAL
|
||||
|
||||
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
|
||||
return decodeSource(uri, size, signal)
|
||||
// A "load original" request is unsized -> a full-res decode (a sized > 768 just samples to target).
|
||||
return decodeSource(uri, size, signal) to orientation
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.loadThumbnail(uri, size, signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
return bitmap to orientation
|
||||
}
|
||||
|
||||
private fun isRawMime(uri: Uri): Boolean {
|
||||
val mime = resolver.getType(uri) ?: return false
|
||||
return mime.startsWith("image/x-") || mime == "image/dng"
|
||||
}
|
||||
|
||||
private fun rawOrientation(uri: Uri): Int {
|
||||
return resolver.openInputStream(uri)?.use {
|
||||
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||
}
|
||||
|
||||
// ImageDecoder / loadThumbnail skip EXIF orientation for raw (e.g. DNG) on Q+, so the decoded
|
||||
// bitmap comes back unrotated. Rotate it into the output buffer in native code (one pass, no
|
||||
// intermediate rotated bitmap).
|
||||
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
|
||||
signal.throwIfCanceled()
|
||||
// Force ARGB_8888: the native rotate needs a lockable 8888 buffer, and toNativeBuffer() below
|
||||
// allocates width*height*4 (an F16/HDR decode would otherwise under-allocate). No-op for the
|
||||
// common already-8888 case.
|
||||
val src = if (bitmap.config != Bitmap.Config.ARGB_8888) {
|
||||
val converted = bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
bitmap.recycle()
|
||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
try {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.rotate(src, orientation, info)
|
||||
if (pointer == 0L) throw IOException("native rotate failed for orientation $orientation")
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
} finally {
|
||||
if (!src.isRecycled) src.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
|
||||
|
||||
@@ -9,8 +9,6 @@ enum SortOrder {
|
||||
|
||||
enum TextSearchType { context, filename, description, ocr }
|
||||
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
@@ -52,6 +52,8 @@ class RemoteAsset extends BaseAsset {
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
bool get isStacked => stackId != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
|
||||
@@ -182,18 +182,6 @@ 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)));
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
@@ -286,4 +287,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
|
||||
return query.map((row) => row.toDto()!).get();
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
}) {
|
||||
final companion = RemoteAssetEntityCompanion(
|
||||
visibility: visibility.toDriftValue(),
|
||||
isFavorite: isFavorite.toDriftValue(),
|
||||
);
|
||||
return _db.batch((batch) {
|
||||
for (final remoteId in remoteIds) {
|
||||
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,29 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final bool shouldFavorite;
|
||||
final bool favorite;
|
||||
|
||||
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
|
||||
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
|
||||
|
||||
@override
|
||||
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||
|
||||
@override
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
||||
.where(
|
||||
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
||||
)
|
||||
.cast<RemoteAsset>();
|
||||
Iterable<RemoteAsset> filter(ActionScope scope) {
|
||||
final owned = AssetFilter(assets).owned(scope.authUser.id);
|
||||
if (favorite) {
|
||||
return owned.notFavorites();
|
||||
} else {
|
||||
return owned.favorites();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||
@@ -31,8 +35,8 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||
final ActionScope(:ref) = scope;
|
||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
|
||||
final message = shouldFavorite
|
||||
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
||||
final message = favorite
|
||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||
snackbar.success(message);
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -7,6 +8,7 @@ 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';
|
||||
@@ -18,7 +20,6 @@ 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';
|
||||
@@ -28,27 +29,6 @@ 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,
|
||||
@@ -160,10 +140,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Drag selection state
|
||||
static const _autoScrollStep = 175.0;
|
||||
static const _autoScrollDuration = Duration(milliseconds: 125);
|
||||
bool _dragging = false;
|
||||
DragSelectionController? _dragController;
|
||||
TimelineAssetIndex? _dragAnchorIndex;
|
||||
final Set<BaseAsset> _draggedAssets = HashSet();
|
||||
ScrollPhysics? _scrollPhysics;
|
||||
|
||||
int _perRow = 4;
|
||||
@@ -247,18 +226,26 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dragController?.dispose();
|
||||
_scrollController.dispose();
|
||||
_eventSubscription?.cancel();
|
||||
super.dispose();
|
||||
@@ -308,21 +295,9 @@ 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;
|
||||
});
|
||||
}
|
||||
@@ -338,12 +313,8 @@ 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);
|
||||
@@ -351,33 +322,42 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
}
|
||||
|
||||
void _dragScroll(ScrollDirection direction) {
|
||||
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);
|
||||
}
|
||||
_scrollController.animateTo(
|
||||
_scrollController.offset + (direction == ScrollDirection.forward ? 175 : -175),
|
||||
duration: const Duration(milliseconds: 125),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDragAssetEnter(TimelineAssetIndex index) {
|
||||
if (!_dragging) {
|
||||
if (_dragAnchorIndex == null || !_dragging) {
|
||||
return;
|
||||
}
|
||||
_dragController?.enter(index.assetIndex);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||
|
||||
final toastRepositoryProvider = Provider<ToastRepository>((ref) => const .new());
|
||||
@@ -97,15 +97,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
final assetApiRepositoryProvider = Provider(
|
||||
(ref) => AssetApiRepository(
|
||||
@@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository {
|
||||
return response?.count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
|
||||
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
|
||||
}
|
||||
|
||||
@@ -77,11 +79,11 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
|
||||
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
|
||||
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
|
||||
AssetVisibilityEnum.locked => AssetVisibility.locked,
|
||||
AssetVisibilityEnum.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
|
||||
AssetVisibility.timeline => api.AssetVisibility.timeline,
|
||||
AssetVisibility.hidden => api.AssetVisibility.hidden,
|
||||
AssetVisibility.locked => api.AssetVisibility.locked,
|
||||
AssetVisibility.archive => api.AssetVisibility.archive,
|
||||
};
|
||||
|
||||
Future<String?> getAssetMIMEType(String assetId) async {
|
||||
@@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> removeEdits(String assetId) async {
|
||||
return _api.removeAssetEdits(assetId);
|
||||
}
|
||||
|
||||
Future<void> update(
|
||||
List<String> remoteIds, {
|
||||
Option<bool> isFavorite = const .none(),
|
||||
Option<AssetVisibility> visibility = const .none(),
|
||||
}) {
|
||||
return _api.updateAssets(
|
||||
AssetBulkUpdateDto(
|
||||
ids: remoteIds,
|
||||
isFavorite: isFavorite.toOptional(),
|
||||
visibility: visibility.map(_mapVisibility).toOptional(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
class ToastOption {
|
||||
final Duration? timeout;
|
||||
final FutureOr<void> Function()? onUndo;
|
||||
|
||||
const ToastOption({this.timeout, this.onUndo});
|
||||
}
|
||||
|
||||
class ToastRepository {
|
||||
const ToastRepository();
|
||||
|
||||
FutureOr<void> success(String message, {ToastOption? toast}) {
|
||||
snackbar.success(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> info(String message, {ToastOption? toast}) {
|
||||
snackbar.info(message, duration: toast?.timeout);
|
||||
}
|
||||
|
||||
FutureOr<void> error(String message, {ToastOption? toast}) {
|
||||
snackbar.error(message, duration: toast?.timeout);
|
||||
}
|
||||
}
|
||||
@@ -79,17 +79,17 @@ class ActionService {
|
||||
}
|
||||
|
||||
Future<void> archive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .archive);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
||||
}
|
||||
|
||||
Future<void> unArchive(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||
|
||||
// Ask user if they want to delete local copies
|
||||
@@ -99,7 +99,7 @@ class ActionService {
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
|
||||
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
|
||||
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
|
||||
|
||||
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
|
||||
AssetFilter<T> favorites() => where(_isFavorite);
|
||||
AssetFilter<T> notFavorites() => whereNot(_isFavorite);
|
||||
|
||||
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
|
||||
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
|
||||
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) => remote().where(_hasVisibility(visibility));
|
||||
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) => remote().whereNot(_hasVisibility(visibility));
|
||||
AssetFilter<RemoteAsset> archived() => visibility(.archive);
|
||||
AssetFilter<RemoteAsset> notArchived() => notVisibility(.archive);
|
||||
AssetFilter<RemoteAsset> stacked() => remote().where(_isStacked);
|
||||
AssetFilter<RemoteAsset> notStacked() => remote().whereNot(_isStacked);
|
||||
|
||||
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||
AssetFilter<LocalAsset> backedUp() => local().where(_isBackedUp);
|
||||
}
|
||||
|
||||
bool _isFavorite(BaseAsset asset) => asset.isFavorite;
|
||||
bool _isStacked(RemoteAsset asset) => asset.isStacked;
|
||||
bool _isBackedUp(LocalAsset asset) => asset.remoteAssetId != null;
|
||||
bool Function(RemoteAsset asset) _hasVisibility(AssetVisibility visibility) =>
|
||||
(asset) => asset.visibility == visibility;
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
|
||||
sealed class Option<T> {
|
||||
@@ -21,6 +22,11 @@ sealed class Option<T> {
|
||||
None() => null,
|
||||
};
|
||||
|
||||
Option<U> map<U>(U Function(T value) f) => switch (this) {
|
||||
Some(:final value) => Some(f(value)),
|
||||
None() => None<U>(),
|
||||
};
|
||||
|
||||
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||
Some(:final value) => onSome(value),
|
||||
None() => onNone(),
|
||||
@@ -65,3 +71,10 @@ extension OptionToOptional<T> on Option<T> {
|
||||
Some(:final value) => Optional.present(value),
|
||||
};
|
||||
}
|
||||
|
||||
extension OptionToDriftValue<T> on Option<T> {
|
||||
Value<T> toDriftValue() => switch (this) {
|
||||
Some(:final value) => Value(value),
|
||||
None() => const Value.absent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
class SnackbarManager {
|
||||
const SnackbarManager();
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
|
||||
String message,
|
||||
SnackbarType type, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
final context = scaffoldMessengerKey.currentContext;
|
||||
if (messenger == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
duration ??= const .new(seconds: 4);
|
||||
messenger.hideCurrentSnackBar();
|
||||
return messenger.showSnackBar(_build(context, message, type));
|
||||
return messenger.showSnackBar(_build(context, message, type, duration));
|
||||
}
|
||||
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type) {
|
||||
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||
@@ -29,7 +34,7 @@ class SnackbarManager {
|
||||
return SnackBar(
|
||||
behavior: .floating,
|
||||
backgroundColor: background,
|
||||
duration: const .new(seconds: 4),
|
||||
duration: duration,
|
||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||
content: Row(
|
||||
children: [
|
||||
@@ -48,11 +53,14 @@ class SnackbarManager {
|
||||
);
|
||||
}
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
|
||||
show(message, .info, duration: duration);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
|
||||
show(message, .success, duration: duration);
|
||||
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
|
||||
show(message, .error, duration: duration);
|
||||
}
|
||||
|
||||
const snackbar = SnackbarManager();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
Logger.root.level = Level.OFF;
|
||||
EasyLocalization.logger.enableBuildModes = [];
|
||||
// ignore: banned-usage
|
||||
debugPrint = (String? message, {int? wrapWidth}) {};
|
||||
return testMain();
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@Skip('Flaky test, needs investigation')
|
||||
@Tags(['widget'])
|
||||
library;
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
@@ -5,7 +5,14 @@ import '../../utils.dart';
|
||||
class RemoteAssetFactory {
|
||||
const RemoteAssetFactory();
|
||||
|
||||
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
|
||||
static RemoteAsset create({
|
||||
String? id,
|
||||
String? name,
|
||||
String? ownerId,
|
||||
bool isFavorite = false,
|
||||
AssetVisibility visibility = AssetVisibility.timeline,
|
||||
String? stackId,
|
||||
}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return RemoteAsset(
|
||||
@@ -17,6 +24,8 @@ class RemoteAssetFactory {
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isFavorite: isFavorite,
|
||||
visibility: visibility,
|
||||
stackId: stackId,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
+56
-14
@@ -1,7 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart' as mock;
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -12,11 +15,11 @@ import 'factories/local_asset_factory.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
|
||||
class RepositoryMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||
|
||||
RepositoryMocks() {
|
||||
resetAll();
|
||||
@@ -24,17 +27,34 @@ class RepositoryMocks {
|
||||
|
||||
void resetAll() {
|
||||
_registerFallbacks();
|
||||
reset(localAlbum);
|
||||
reset(localAsset);
|
||||
localAlbum.reset();
|
||||
localAsset.reset();
|
||||
reset(trashedAsset);
|
||||
reset(nativeApi);
|
||||
nativeApi.reset();
|
||||
_stubLocalAlbumRepository();
|
||||
_stubLocalAssetRepository();
|
||||
_stubNativeSyncApi();
|
||||
}
|
||||
|
||||
void _stubLocalAlbumRepository() {
|
||||
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
|
||||
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
|
||||
}
|
||||
|
||||
void _stubLocalAssetRepository() {
|
||||
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
|
||||
when(localAsset.updateHashes).thenAnswer((_) async => {});
|
||||
}
|
||||
|
||||
void _stubNativeSyncApi() {
|
||||
when(nativeApi.hashAssets).thenAnswer((_) async => []);
|
||||
}
|
||||
}
|
||||
|
||||
class ServiceMocks {
|
||||
final PartnerStub partner = PartnerStub(MockPartnerService());
|
||||
final UserStub user = UserStub(MockUserService());
|
||||
final asset = AssetStub(MockAssetService());
|
||||
final partner = PartnerServiceStub(MockPartnerService());
|
||||
final user = UserServiceStub(MockUserService());
|
||||
final asset = AssetServiceStub(MockAssetService());
|
||||
|
||||
ServiceMocks() {
|
||||
resetAll();
|
||||
@@ -78,11 +98,28 @@ void _registerFallbacks() {
|
||||
registerFallbackValue(Uint8List(0));
|
||||
}
|
||||
|
||||
extension type const Stub<T extends Mock>(T mockedService) {
|
||||
void reset() => mock.reset(mockedService);
|
||||
extension type const Stub<T extends Mock>(T mockedClass) {
|
||||
void reset() => mock.reset(mockedClass);
|
||||
}
|
||||
|
||||
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
|
||||
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
|
||||
() => repo.getBackupAlbums();
|
||||
|
||||
Future<List<LocalAsset>> Function() get getAssetsToHash =>
|
||||
() => repo.getAssetsToHash(any());
|
||||
}
|
||||
|
||||
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
|
||||
implements Stub<MockDriftLocalAssetRepository> {
|
||||
Future<void> Function() get reconcileHashesFromCloudId =>
|
||||
() => repo.reconcileHashesFromCloudId();
|
||||
|
||||
Future<void> Function() get updateHashes =>
|
||||
() => repo.updateHashes(any());
|
||||
}
|
||||
|
||||
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
|
||||
Stream<Iterable<User>> Function() get getCandidates =>
|
||||
() => service.getCandidates(any());
|
||||
|
||||
@@ -110,7 +147,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub<Moc
|
||||
);
|
||||
}
|
||||
|
||||
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
|
||||
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
|
||||
UserDto Function() get getMyUser =>
|
||||
() => service.getMyUser();
|
||||
|
||||
@@ -127,7 +164,12 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
|
||||
() => service.createProfileImage(any(), any());
|
||||
}
|
||||
|
||||
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||
Future<void> Function() get updateFavorite =>
|
||||
() => service.updateFavorite(any(), any());
|
||||
}
|
||||
|
||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||
Future<List<HashResult>> Function() get hashAssets =>
|
||||
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
@@ -23,8 +23,8 @@ void main() {
|
||||
group('AssetDebugAction', () {
|
||||
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
@@ -32,10 +32,10 @@ void main() {
|
||||
|
||||
testWidgets('hidden for multiple assets', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(
|
||||
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
|
||||
),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
@@ -44,8 +44,8 @@ void main() {
|
||||
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
|
||||
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockAssetService assetService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
assetService = context.service.asset.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> overrides() => [
|
||||
...context.overrides,
|
||||
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
|
||||
];
|
||||
|
||||
RemoteAsset owned({bool isFavorite = false}) =>
|
||||
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
|
||||
|
||||
@@ -32,48 +28,48 @@ void main() {
|
||||
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||
final asset = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([asset.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
||||
final asset = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [asset]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
|
||||
verify(() => assetService.updateFavorite([asset.id], false)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||
final mine = owned();
|
||||
final theirs = RemoteAssetFactory.create();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [mine, theirs]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([mine.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||
final first = owned();
|
||||
final second = owned();
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [first, second]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([first.id, second.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('skips owned assets already in the target state', (tester) async {
|
||||
final stale = owned();
|
||||
final alreadyFavorite = owned(isFavorite: true);
|
||||
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [stale, alreadyFavorite]));
|
||||
|
||||
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
|
||||
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
||||
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
|
||||
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
|
||||
await tester.pumpUntilFound(find.byType(SnackBar));
|
||||
|
||||
expect(find.byType(SnackBar), findsOneWidget);
|
||||
|
||||
@@ -4,17 +4,19 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../domain/service.mock.dart';
|
||||
import '../../factories/user_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
late MockPartnerService partnerService;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
partnerService = context.service.partner.service;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -22,8 +24,6 @@ void main() {
|
||||
});
|
||||
|
||||
List<Override> overrides({List<User> candidates = const []}) => [
|
||||
...context.overrides,
|
||||
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
@@ -31,22 +31,24 @@ void main() {
|
||||
testWidgets('creates a partner for the selected candidate', (tester) async {
|
||||
final candidate = UserFactory.create();
|
||||
|
||||
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpTestAction(context, const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
|
||||
await tester.pumpUntilFound(find.text(candidate.name));
|
||||
await tester.tap(find.text(candidate.name));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
|
||||
).called(1);
|
||||
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
|
||||
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
const PartnerAddAction(),
|
||||
overrides: overrides(candidates: [UserFactory.create()]),
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.mocks.partner.create);
|
||||
verifyNever(context.service.partner.create);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,27 +56,27 @@ void main() {
|
||||
testWidgets('deletes the partner after confirmation', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).last); // confirm
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
|
||||
).called(1);
|
||||
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
|
||||
final partner = UserFactory.create();
|
||||
await tester.pumpTestAction(
|
||||
context,
|
||||
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
|
||||
overrides: overrides(),
|
||||
);
|
||||
await tester.tap(find.byType(TextButton).first); // cancel
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(context.mocks.partner.delete);
|
||||
verifyNever(context.service.partner.delete);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../../presentation_context.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
class _FakeAction extends BaseAction {
|
||||
_FakeAction({this.visible = true, this.error});
|
||||
@@ -48,8 +48,7 @@ void main() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
List<Override> seededOverrides() => [
|
||||
...context.overrides,
|
||||
List<Override> overrides() => [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(
|
||||
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
|
||||
@@ -61,6 +60,7 @@ void main() {
|
||||
late ActionScope scope;
|
||||
late ProviderContainer container;
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
Consumer(
|
||||
builder: (innerContext, ref, _) {
|
||||
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
|
||||
@@ -68,7 +68,7 @@ void main() {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
overrides: seededOverrides(),
|
||||
overrides: overrides(),
|
||||
);
|
||||
return (scope, container);
|
||||
}
|
||||
@@ -97,8 +97,8 @@ void main() {
|
||||
|
||||
testWidgets('delegates visibility to the wrapped action', (tester) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
|
||||
overrides: context.overrides,
|
||||
);
|
||||
|
||||
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||
|
||||
import '../factories/partner_user_factory.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
import 'presentation_context.dart';
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
@@ -19,7 +19,7 @@ void main() {
|
||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||
final action = const PartnerAddAction();
|
||||
|
||||
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
|
||||
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
|
||||
|
||||
expect(find.byType(ListView), findsNothing);
|
||||
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
|
||||
@@ -28,8 +28,7 @@ void main() {
|
||||
testWidgets('renders a tile per partner with name and email', (tester) async {
|
||||
final partner1 = PartnerFactory.create();
|
||||
final partner2 = PartnerFactory.create();
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
|
||||
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byType(ListTile), findsNWidgets(2));
|
||||
expect(find.text(partner1.name), findsOneWidget);
|
||||
expect(find.text(partner1.email), findsOneWidget);
|
||||
@@ -41,7 +40,7 @@ void main() {
|
||||
final partner1 = PartnerFactory.create(inTimeline: true);
|
||||
final partner2 = PartnerFactory.create();
|
||||
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
|
||||
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
|
||||
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
|
||||
expect(find.byIcon(action.icon), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
@@ -62,13 +61,12 @@ void main() {
|
||||
}
|
||||
|
||||
List<Override> withCandidates(List<User> candidates) => [
|
||||
...context.overrides,
|
||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||
];
|
||||
|
||||
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
|
||||
final user = UserFactory.create();
|
||||
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
|
||||
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -78,7 +76,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows no options when the provider returns no candidates', (tester) async {
|
||||
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
|
||||
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -89,7 +87,11 @@ void main() {
|
||||
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
|
||||
final user = UserFactory.create();
|
||||
User? selected;
|
||||
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
dialogWidget(onClosed: (user) => selected = user),
|
||||
overrides: withCandidates([user]),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(dialogButtonKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
+26
-12
@@ -13,16 +13,21 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../test_utils.dart';
|
||||
import 'factories/user_factory.dart';
|
||||
import 'mocks.dart';
|
||||
import '../../test_utils.dart';
|
||||
import '../factories/user_factory.dart';
|
||||
import '../mocks.dart';
|
||||
|
||||
class PresentationContext {
|
||||
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
|
||||
PresentationContext._({required UserDto user})
|
||||
: currentUser = user,
|
||||
service = ServiceMocks(),
|
||||
repository = RepositoryMocks() {
|
||||
setup();
|
||||
}
|
||||
|
||||
@@ -31,9 +36,14 @@ class PresentationContext {
|
||||
static Drift? _db;
|
||||
|
||||
final UserDto currentUser;
|
||||
final ServiceMocks mocks;
|
||||
final ServiceMocks service;
|
||||
final RepositoryMocks repository;
|
||||
|
||||
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
|
||||
List<Override> get overrides => [
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||
];
|
||||
|
||||
static Future<PresentationContext> create() async {
|
||||
TestUtils.init();
|
||||
@@ -47,18 +57,18 @@ class PresentationContext {
|
||||
}
|
||||
|
||||
void setup() {
|
||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||
when(service.user.tryGetMyUser).thenReturn(currentUser);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
addTearDown(() {
|
||||
mocks.resetAll();
|
||||
service.resetAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PumpPresentationWidget on WidgetTester {
|
||||
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
|
||||
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
@@ -69,7 +79,7 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: overrides,
|
||||
overrides: [...context.overrides, ...overrides],
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
@@ -86,8 +96,12 @@ extension PumpPresentationWidget on WidgetTester {
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
Future<void> pumpTestAction(
|
||||
PresentationContext context,
|
||||
BaseAction action, {
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
await pumpTestWidget(context, ActionIconButtonWidget(action: action), overrides: overrides);
|
||||
await tap(find.byType(ImmichIconButton));
|
||||
await pump();
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14,14 +14,11 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
|
||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -32,22 +29,20 @@ void main() {
|
||||
group('hashAssets', () {
|
||||
test('skips albums with no assets to hash', () async {
|
||||
final album = LocalAlbumFactory.create(assetCount: 0);
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(mocks.nativeApi.hashAssets);
|
||||
});
|
||||
|
||||
test('skips empty batches', () async {
|
||||
final album = LocalAlbumFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
verifyNever(mocks.nativeApi.hashAssets);
|
||||
});
|
||||
|
||||
test('processes assets when available', () async {
|
||||
@@ -55,15 +50,17 @@ void main() {
|
||||
final asset = LocalAssetFactory.create();
|
||||
final result = HashResult(assetId: asset.id, hash: 'test-hash');
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [result]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset.id], result.hash);
|
||||
});
|
||||
@@ -72,16 +69,16 @@ void main() {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
@@ -89,25 +86,25 @@ void main() {
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 0);
|
||||
});
|
||||
|
||||
test('batches by size limit', () async {
|
||||
const batchSize = 2;
|
||||
final sut = HashService(
|
||||
localAlbumRepository: mocks.localAlbum,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
localAlbumRepository: mocks.localAlbum.repo,
|
||||
localAssetRepository: mocks.localAsset.repo,
|
||||
nativeSyncApi: mocks.nativeApi.api,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
);
|
||||
@@ -119,12 +116,9 @@ void main() {
|
||||
|
||||
final capturedCalls = <List<String>>[];
|
||||
|
||||
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
|
||||
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
capturedCalls.add(List<String>.from(assetIds));
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
@@ -136,7 +130,7 @@ void main() {
|
||||
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
|
||||
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
|
||||
|
||||
verify(() => mocks.localAsset.updateHashes(any())).called(2);
|
||||
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
|
||||
});
|
||||
|
||||
test('handles mixed success and failure in batch', () async {
|
||||
@@ -144,9 +138,9 @@ void main() {
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
|
||||
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
|
||||
(_) async => [
|
||||
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
|
||||
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
|
||||
@@ -156,7 +150,7 @@ void main() {
|
||||
await sut.hashAssets();
|
||||
|
||||
final captured =
|
||||
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
|
||||
expect(captured.length, 1);
|
||||
expect(captured[asset1.id], 'asset1-hash');
|
||||
});
|
||||
@@ -167,20 +161,18 @@ void main() {
|
||||
final asset1 = LocalAssetFactory.create();
|
||||
final asset2 = LocalAssetFactory.create();
|
||||
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
|
||||
invocation,
|
||||
) async {
|
||||
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
|
||||
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
|
||||
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
|
||||
final assetIds = invocation.positionalArguments[0] as List<String>;
|
||||
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
|
||||
});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
|
||||
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||
|
||||
import '../factories/local_asset_factory.dart';
|
||||
import '../factories/remote_asset_factory.dart';
|
||||
|
||||
void main() {
|
||||
group('AssetFilter', () {
|
||||
group('type promotion', () {
|
||||
test('a bare filter retains every BaseAsset', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<BaseAsset> filter = AssetFilter(<BaseAsset>[remoteAsset, localAsset]);
|
||||
|
||||
expect(filter.toList(), [remoteAsset, localAsset]);
|
||||
});
|
||||
|
||||
test('remote keeps only remote assets', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).remote();
|
||||
|
||||
expect(remoteOnly.toList(), [remoteAsset]);
|
||||
});
|
||||
|
||||
test('local keeps only local assets', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<LocalAsset> localOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).local();
|
||||
|
||||
expect(localOnly.toList(), [localAsset]);
|
||||
});
|
||||
|
||||
test('owned promotes to RemoteAsset and drops local assets', () {
|
||||
final remoteAsset = RemoteAssetFactory.create();
|
||||
final localAsset = LocalAssetFactory.create();
|
||||
|
||||
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[
|
||||
remoteAsset,
|
||||
localAsset,
|
||||
]).owned(remoteAsset.ownerId);
|
||||
|
||||
expect(remoteOnly.toList(), [remoteAsset]);
|
||||
});
|
||||
|
||||
test('backedUp promotes to LocalAsset and drops remote assets', () {
|
||||
final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote');
|
||||
final offlinePhoto = LocalAssetFactory.create();
|
||||
final remotePhoto = RemoteAssetFactory.create();
|
||||
|
||||
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||
syncedPhoto,
|
||||
offlinePhoto,
|
||||
remotePhoto,
|
||||
]).backedUp();
|
||||
|
||||
expect(syncedPhotos.toList(), [syncedPhoto]);
|
||||
});
|
||||
});
|
||||
|
||||
group('named filters', () {
|
||||
test('owned keeps only assets of the given owner', () {
|
||||
final asset1 = RemoteAssetFactory.create();
|
||||
final asset2 = RemoteAssetFactory.create();
|
||||
|
||||
final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId);
|
||||
|
||||
expect(alexPhotos.toList(), [asset1]);
|
||||
});
|
||||
|
||||
test('favorites keeps only favorite assets', () {
|
||||
final asset1 = RemoteAssetFactory.create(isFavorite: true);
|
||||
final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId);
|
||||
|
||||
final favorites = AssetFilter([asset1, asset2]).favorites();
|
||||
|
||||
expect(favorites.toList(), [asset1]);
|
||||
});
|
||||
|
||||
test('type keeps only assets of the given type', () {
|
||||
final image = RemoteAssetFactory.create();
|
||||
final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video);
|
||||
|
||||
final videos = AssetFilter([image, video]).type(.video);
|
||||
|
||||
expect(videos.toList(), [video]);
|
||||
});
|
||||
|
||||
test('visibility keeps only assets with the given visibility', () {
|
||||
final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId);
|
||||
|
||||
final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked);
|
||||
|
||||
expect(lockedPhotos.toList(), [locked]);
|
||||
});
|
||||
|
||||
test('archived keeps only archived assets', () {
|
||||
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId);
|
||||
|
||||
final archivedPhotos = AssetFilter([archived, onTimeline]).archived();
|
||||
|
||||
expect(archivedPhotos.toList(), [archived]);
|
||||
});
|
||||
|
||||
test('stacked keeps only assets belonging to a stack', () {
|
||||
final stacked = RemoteAssetFactory.create(stackId: 'stack');
|
||||
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
|
||||
|
||||
final stackedPhotos = AssetFilter([stacked, loose]).stacked();
|
||||
|
||||
expect(stackedPhotos.toList(), [stacked]);
|
||||
});
|
||||
});
|
||||
|
||||
group('inversion', () {
|
||||
test('notArchived keeps every non-archived visibility', () {
|
||||
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
|
||||
final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden);
|
||||
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
|
||||
|
||||
final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).notArchived();
|
||||
|
||||
expect(visiblePhotos.toSet(), {onTimeline, hidden, locked});
|
||||
});
|
||||
|
||||
test('notVisibility keeps every asset not at the target visibility', () {
|
||||
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
|
||||
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
|
||||
|
||||
final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive);
|
||||
|
||||
expect(toArchive.toSet(), {onTimeline, locked});
|
||||
});
|
||||
|
||||
test('notStacked keeps only assets without a stack', () {
|
||||
final stacked = RemoteAssetFactory.create(stackId: 'stack');
|
||||
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
|
||||
|
||||
final loosePhotos = AssetFilter([stacked, loose]).notStacked();
|
||||
|
||||
expect(loosePhotos.toList(), [loose]);
|
||||
});
|
||||
|
||||
test('whereNot inverts an arbitrary predicate', () {
|
||||
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||
|
||||
final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite);
|
||||
|
||||
expect(nonFavorites.toList(), [regular]);
|
||||
});
|
||||
|
||||
test('notFavorites keeps only non-favorite assets', () {
|
||||
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||
|
||||
final nonFavorites = AssetFilter([favorite, regular]).notFavorites();
|
||||
|
||||
expect(nonFavorites.toList(), [regular]);
|
||||
});
|
||||
});
|
||||
|
||||
group('chaining', () {
|
||||
test('combines predicates across owner, visibility and stack', () {
|
||||
final asset = RemoteAssetFactory.create();
|
||||
final wrongOwner = RemoteAssetFactory.create();
|
||||
final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive);
|
||||
final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1');
|
||||
final localPhoto = LocalAssetFactory.create();
|
||||
|
||||
final result = AssetFilter(<BaseAsset>[
|
||||
asset,
|
||||
wrongOwner,
|
||||
archived,
|
||||
stacked,
|
||||
localPhoto,
|
||||
]).owned(asset.ownerId).notArchived().notStacked();
|
||||
|
||||
expect(result.toList(), [asset]);
|
||||
});
|
||||
|
||||
test('a base filter after a promotion retains the promoted type', () {
|
||||
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||
|
||||
final AssetFilter<RemoteAsset> result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorites();
|
||||
|
||||
expect(result.toList(), [favorite]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,27 +1,51 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/locales.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
|
||||
extension PumpConsumerWidget on WidgetTester {
|
||||
/// Wraps the provided [widget] with Material app such that it becomes:
|
||||
/// Wraps the provided [widget] with a localized Material app such that it
|
||||
/// becomes:
|
||||
///
|
||||
/// EasyLocalization
|
||||
/// |-ProviderScope
|
||||
/// |-MaterialApp (localization delegates wired up)
|
||||
/// |-Material
|
||||
/// |-[widget]
|
||||
///
|
||||
/// ProviderScope
|
||||
/// |-MaterialApp
|
||||
/// |-Material
|
||||
/// |-[widget]
|
||||
Future<void> pumpConsumerWidget(
|
||||
Widget widget, {
|
||||
Duration? duration,
|
||||
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
return pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: overrides,
|
||||
child: MaterialApp(debugShowCheckedModeBanner: false, home: Material(child: widget)),
|
||||
await pumpWidget(
|
||||
EasyLocalization(
|
||||
supportedLocales: locales.values.toList(),
|
||||
path: translationsPath,
|
||||
startLocale: locales.values.first,
|
||||
fallbackLocale: locales.values.first,
|
||||
saveLocale: false,
|
||||
useFallbackTranslations: true,
|
||||
assetLoader: const CodegenLoader(),
|
||||
child: ProviderScope(
|
||||
overrides: overrides,
|
||||
child: Builder(
|
||||
builder: (context) => MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: context.localizationDelegates,
|
||||
supportedLocales: context.supportedLocales,
|
||||
locale: context.locale,
|
||||
home: Material(child: widget),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
duration: duration,
|
||||
phase: phase,
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +53,10 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||
RegionList: {
|
||||
Area: {
|
||||
// (X,Y) // center of the rectangle
|
||||
X: number;
|
||||
Y: number;
|
||||
W: number;
|
||||
H: number;
|
||||
X: number | string;
|
||||
Y: number | string;
|
||||
W: number | string;
|
||||
H: number | string;
|
||||
Unit: string;
|
||||
};
|
||||
Rotation?: number;
|
||||
|
||||
@@ -39,7 +39,10 @@ const forSidecarJob = (
|
||||
};
|
||||
};
|
||||
|
||||
const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: ImmichTags['Orientation']) => ({
|
||||
const makeFaceTags = (
|
||||
face: Partial<{ Name: string }> = {},
|
||||
orientation?: ImmichTags['Orientation'],
|
||||
): Partial<ImmichTags> => ({
|
||||
Orientation: orientation,
|
||||
RegionInfo: {
|
||||
AppliedToDimensions: { W: 1000, H: 100, Unit: 'pixel' },
|
||||
@@ -1371,6 +1374,35 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.person.updateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle string coordinates in face region bounding box calculation by limiting to 16 decimal places', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
const faceTags = makeFaceTags({ Name: person.name });
|
||||
|
||||
// Simulating EXIF returning a string with >16 decimal places
|
||||
faceTags.RegionInfo!.RegionList[0].Area.X = '0.48564814814814824';
|
||||
faceTags.RegionInfo!.RegionList[0].Area.W = '0.2';
|
||||
|
||||
mockReadTags(faceTags);
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([person.id]);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
boundingBoxX1: Math.floor((0.485_648_148_148_148_2 - 0.2 / 2) * 1000),
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply metadata face tags creating new people', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
@@ -854,6 +854,13 @@ export class MetadataService extends BaseService {
|
||||
// update area coordinates and dimensions in RegionList assuming "normalized" unit as per MWG guidelines
|
||||
const adjustedRegionList = regionInfo.RegionList.map((region) => {
|
||||
let { X, Y, W, H } = region.Area;
|
||||
|
||||
// EXIF floats with >16 decimals are serialized as strings. Ensure they are numbers.
|
||||
X = Number(X);
|
||||
Y = Number(Y);
|
||||
W = Number(W);
|
||||
H = Number(H);
|
||||
|
||||
switch (orientation) {
|
||||
case ExifOrientation.MirrorHorizontal: {
|
||||
X = 1 - X;
|
||||
@@ -926,16 +933,21 @@ export class MetadataService extends BaseService {
|
||||
const loweredName = region.Name.toLowerCase();
|
||||
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
|
||||
|
||||
const X = Number(region.Area.X);
|
||||
const Y = Number(region.Area.Y);
|
||||
const W = Number(region.Area.W);
|
||||
const H = Number(region.Area.H);
|
||||
|
||||
const face = {
|
||||
id: this.cryptoRepository.randomUUID(),
|
||||
personId,
|
||||
assetId: asset.id,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth),
|
||||
boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight),
|
||||
boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth),
|
||||
boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight),
|
||||
boundingBoxX1: Math.floor((X - W / 2) * imageWidth),
|
||||
boundingBoxY1: Math.floor((Y - H / 2) * imageHeight),
|
||||
boundingBoxX2: Math.floor((X + W / 2) * imageWidth),
|
||||
boundingBoxY2: Math.floor((Y + H / 2) * imageHeight),
|
||||
sourceType: SourceType.Exif,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user