mirror of
https://github.com/immich-app/immich.git
synced 2026-07-02 02:55:01 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da89c75bdd | |||
| 2272583a7e | |||
| 73f8e90f0f | |||
| 2999b00e5d | |||
| c668bd3342 | |||
| c3a02b179a | |||
| 6b77c90e1c | |||
| df383c1ead | |||
| af2efda310 |
@@ -7,6 +7,7 @@ project(native_buffer LANGUAGES C)
|
|||||||
|
|
||||||
add_library(native_buffer SHARED
|
add_library(native_buffer SHARED
|
||||||
src/main/cpp/native_buffer.c
|
src/main/cpp/native_buffer.c
|
||||||
|
src/main/cpp/native_image.c
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(native_buffer jnigraphics)
|
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.provider.MediaStore.Video
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
|
import app.alextran.immich.NativeImage
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@@ -181,35 +183,88 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
|||||||
val id = assetId.toLong()
|
val id = assetId.toLong()
|
||||||
|
|
||||||
signal.throwIfCanceled()
|
signal.throwIfCanceled()
|
||||||
val bitmap = if (isVideo) {
|
|
||||||
decodeVideoThumbnail(id, size, signal)
|
|
||||||
} else {
|
|
||||||
decodeImage(id, size, signal)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
signal.throwIfCanceled()
|
val res = if (isVideo) {
|
||||||
val res = bitmap.toNativeBuffer()
|
decodeVideoThumbnail(id, size, signal).toNativeBuffer()
|
||||||
signal.throwIfCanceled()
|
} 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))
|
callback(Result.success(res))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
|
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()
|
signal.throwIfCanceled()
|
||||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
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) {
|
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)
|
resolver.loadThumbnail(uri, size, signal)
|
||||||
} else {
|
} else {
|
||||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
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 {
|
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ enum SortOrder {
|
|||||||
|
|
||||||
enum TextSearchType { context, filename, description, ocr }
|
enum TextSearchType { context, filename, description, ocr }
|
||||||
|
|
||||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
|
||||||
|
|
||||||
enum ActionSource { timeline, viewer }
|
enum ActionSource { timeline, viewer }
|
||||||
|
|
||||||
enum ShareAssetType { original, preview }
|
enum ShareAssetType { original, preview }
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class RemoteAsset extends BaseAsset {
|
|||||||
|
|
||||||
bool get isTrashed => deletedAt != null;
|
bool get isTrashed => deletedAt != null;
|
||||||
|
|
||||||
|
bool get isStacked => stackId != null;
|
||||||
|
|
||||||
|
bool get isArchived => visibility == .archive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''Asset {
|
return '''Asset {
|
||||||
|
|||||||
@@ -77,4 +77,31 @@ class AssetService {
|
|||||||
await _apiRepository.updateFavorite(remoteIds, isFavorite);
|
await _apiRepository.updateFavorite(remoteIds, isFavorite);
|
||||||
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
|
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> restoreTrash(List<String> remoteIds) async {
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiRepository.restoreTrash(remoteIds);
|
||||||
|
await _remoteRepository.restoreTrash(remoteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stack = await _apiRepository.stack(remoteIds);
|
||||||
|
await _remoteRepository.stack(userId, stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unstack(List<String> stackIds) async {
|
||||||
|
if (stackIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _remoteRepository.unStack(stackIds);
|
||||||
|
await _apiRepository.unStack(stackIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/remote_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/stack.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/infrastructure/repositories/db.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/option.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class RemoteAssetRepository extends DriftDatabaseRepository {
|
class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||||
@@ -286,4 +287,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
|
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
|
||||||
return query.map((row) => row.toDto()!).get();
|
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,38 +3,36 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
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
|
@override
|
||||||
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
Iterable<RemoteAsset> filter(ActionScope scope) =>
|
||||||
.where(
|
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
|
||||||
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
|
||||||
)
|
|
||||||
.cast<RemoteAsset>();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onAction(ActionScope scope) async {
|
Future<void> onAction(ActionScope scope) async {
|
||||||
final ActionScope(:ref) = scope;
|
final ActionScope(:ref, :context) = scope;
|
||||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||||
|
|
||||||
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
|
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
||||||
final message = shouldFavorite
|
final message = favorite
|
||||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
? context.t.favorite_action_prompt(count: assets.length)
|
||||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
: context.t.unfavorite_action_prompt(count: assets.length);
|
||||||
snackbar.success(message);
|
ref.read(toastRepositoryProvider).success(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
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/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
class RestoreAction extends AssetAction<RemoteAsset> {
|
||||||
|
const RestoreAction({required super.assets});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.history_rounded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String label(ActionScope scope) => scope.context.t.restore;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id).trashed();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction(ActionScope scope) async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final ids = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||||
|
await ref.read(assetServiceProvider).restoreTrash(ids);
|
||||||
|
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: ids.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
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/providers/infrastructure/toast.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
class StackAction extends AssetAction<RemoteAsset> {
|
||||||
|
final bool stack;
|
||||||
|
|
||||||
|
StackAction({required super.assets}) : stack = assets.any((asset) => asset is RemoteAsset && asset.stackId == null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String label(ActionScope scope) => stack ? scope.context.t.stack : scope.context.t.unstack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<RemoteAsset> filter(ActionScope scope) => AssetFilter(assets).owned(scope.authUser.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isVisible(ActionScope scope) => stack ? filter(scope).length > 1 : filter(scope).isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction(ActionScope scope) async {
|
||||||
|
final ActionScope(:ref, :context) = scope;
|
||||||
|
final assets = filter(scope).toList(growable: false);
|
||||||
|
final service = ref.read(assetServiceProvider);
|
||||||
|
|
||||||
|
if (stack) {
|
||||||
|
await service.stack(scope.authUser.id, assets.map((asset) => asset.id).toList(growable: false));
|
||||||
|
} else {
|
||||||
|
await service.unstack(assets.map((asset) => asset.stackId).nonNulls.toList(growable: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = stack
|
||||||
|
? context.t.stacked_assets_count(count: assets.length)
|
||||||
|
: context.t.unstacked_assets_count(count: assets.length);
|
||||||
|
ref.read(toastRepositoryProvider).success(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class FavoriteActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const FavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).favorite(source);
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
if (result.success) {
|
|
||||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
|
||||||
if (currentAsset is RemoteAsset && !currentAsset.isFavorite) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'favorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.favorite_border_rounded,
|
|
||||||
label: "favorite".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class RestoreActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.history_rounded,
|
|
||||||
label: 'restore'.t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
maxWidth: 100.0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class RestoreTrashActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
|
|
||||||
const RestoreTrashActionButton({super.key, required this.source});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return TextButton.icon(
|
|
||||||
icon: const Icon(Icons.history_rounded),
|
|
||||||
label: Text('restore'.t(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class StackActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
|
|
||||||
const StackActionButton({super.key, required this.source});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final user = ref.watch(currentUserProvider);
|
|
||||||
if (user == null) {
|
|
||||||
throw Exception('User must be logged in to access stack action');
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).stack(user.id, source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'stack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.filter_none_rounded,
|
|
||||||
label: "stack".t(context: context),
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class UnFavoriteActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const UnFavoriteActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).unFavorite(source);
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
|
||||||
if (result.success) {
|
|
||||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
|
||||||
if (currentAsset is RemoteAsset && currentAsset.isFavorite) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(currentAsset.copyWith(isFavorite: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'unfavorite_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.favorite_rounded,
|
|
||||||
label: "unfavorite".t(context: context),
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class UnStackActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).unStack(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
final successMessage = 'unstack_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
|
||||||
gravity: ToastGravity.BOTTOM,
|
|
||||||
toastType: result.success ? ToastType.success : ToastType.error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
iconData: Icons.layers_clear_outlined,
|
|
||||||
label: "unstack".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,13 @@ 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/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
|
||||||
@@ -42,11 +43,10 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
|
final assets = [asset];
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (isInTrash && isOwner && asset.hasRemote)
|
ActionColumnButtonWidget(action: RestoreAction(assets: assets)),
|
||||||
const RestoreActionButton(source: ActionSource.viewer)
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
else
|
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
|
||||||
|
|
||||||
if (!isInLockedView) ...[
|
if (!isInLockedView) ...[
|
||||||
if (!isInTrash) ...[
|
if (!isInTrash) ...[
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
@@ -14,10 +15,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
@@ -77,7 +76,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [FavoriteAction(assets: assets)];
|
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -97,8 +96,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
@@ -16,9 +17,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
@@ -68,7 +67,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [FavoriteAction(assets: assets)];
|
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
initialChildSize: 0.4,
|
initialChildSize: 0.4,
|
||||||
@@ -87,8 +86,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||||
@@ -14,13 +16,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
@@ -84,7 +83,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [AssetDebugAction(assets: assets)];
|
final actions = [AssetDebugAction(assets: assets), FavoriteAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -101,14 +100,11 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const FavoriteActionButton(source: ActionSource.timeline),
|
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
const ArchiveActionButton(source: ActionSource.timeline),
|
||||||
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.onlyLocal || multiselect.hasMerged)
|
if (multiselect.onlyLocal || multiselect.hasMerged)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
@@ -17,9 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_al
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
@@ -86,7 +85,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assets = multiselect.selectedAssets.toList(growable: false);
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
final actions = [FavoriteAction(assets: assets)];
|
final actions = [FavoriteAction(assets: assets), StackAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
@@ -111,8 +110,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -2,26 +2,33 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_trash_action_button.widget.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
class TrashBottomBar extends ConsumerWidget {
|
class TrashBottomBar extends ConsumerWidget {
|
||||||
const TrashBottomBar({super.key});
|
const TrashBottomBar({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: context.themeData.canvasColor,
|
color: context.themeData.canvasColor,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: const SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
const DeleteTrashActionButton(source: ActionSource.timeline),
|
||||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
ActionColumnButtonWidget(
|
||||||
|
action: TimelineAction(action: RestoreAction(assets: assets)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -156,28 +156,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> favorite(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
await _service.favorite(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to favorite assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> unFavorite(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
await _service.unFavorite(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to unfavorite assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> archive(ActionSource source) async {
|
Future<ActionResult> archive(ActionSource source) async {
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
final ids = _getOwnedRemoteIdsForSource(source);
|
||||||
try {
|
try {
|
||||||
@@ -235,17 +213,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> restoreTrash(ActionSource source) async {
|
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
|
||||||
try {
|
|
||||||
await _service.restoreTrash(ids);
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
|
||||||
} catch (error, stack) {
|
|
||||||
_logger.severe('Failed to restore trash assets', error, stack);
|
|
||||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ActionResult> emptyTrash(String userId) async {
|
Future<ActionResult> emptyTrash(String userId) async {
|
||||||
try {
|
try {
|
||||||
final count = await _service.emptyTrash(userId);
|
final count = await _service.emptyTrash(userId);
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -22,8 +22,6 @@ class MultiSelectState {
|
|||||||
bool get hasRemote =>
|
bool get hasRemote =>
|
||||||
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
|
selectedAssets.any((asset) => asset.storage == AssetState.remote || asset.storage == AssetState.merged);
|
||||||
|
|
||||||
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
|
|
||||||
|
|
||||||
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
||||||
|
|
||||||
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:http/http.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/asset_edit.model.dart' hide AssetEditAction;
|
||||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.dart';
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/api.repository.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: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(
|
final assetApiRepositoryProvider = Provider(
|
||||||
(ref) => AssetApiRepository(
|
(ref) => AssetApiRepository(
|
||||||
@@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
return response?.count ?? 0;
|
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))));
|
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);
|
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||||
}
|
}
|
||||||
|
|
||||||
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
|
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
|
||||||
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
|
AssetVisibility.timeline => api.AssetVisibility.timeline,
|
||||||
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
|
AssetVisibility.hidden => api.AssetVisibility.hidden,
|
||||||
AssetVisibilityEnum.locked => AssetVisibility.locked,
|
AssetVisibility.locked => api.AssetVisibility.locked,
|
||||||
AssetVisibilityEnum.archive => AssetVisibility.archive,
|
AssetVisibility.archive => api.AssetVisibility.archive,
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<String?> getAssetMIMEType(String assetId) async {
|
Future<String?> getAssetMIMEType(String assetId) async {
|
||||||
@@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
Future<void> removeEdits(String assetId) async {
|
Future<void> removeEdits(String assetId) async {
|
||||||
return _api.removeAssetEdits(assetId);
|
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 {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,28 +68,18 @@ class ActionService {
|
|||||||
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
unawaited(context.pushRoute(SharedLinkEditRoute(assetsList: remoteIds)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> favorite(List<String> remoteIds) async {
|
|
||||||
await _assetApiRepository.updateFavorite(remoteIds, true);
|
|
||||||
await _remoteAssetRepository.updateFavorite(remoteIds, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> unFavorite(List<String> remoteIds) async {
|
|
||||||
await _assetApiRepository.updateFavorite(remoteIds, false);
|
|
||||||
await _remoteAssetRepository.updateFavorite(remoteIds, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> archive(List<String> remoteIds) async {
|
Future<void> archive(List<String> remoteIds) async {
|
||||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
|
await _assetApiRepository.updateVisibility(remoteIds, .archive);
|
||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unArchive(List<String> remoteIds) async {
|
Future<void> unArchive(List<String> remoteIds) async {
|
||||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
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);
|
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||||
|
|
||||||
// Ask user if they want to delete local copies
|
// Ask user if they want to delete local copies
|
||||||
@@ -99,7 +89,7 @@ class ActionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||||
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
|
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
|
||||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +98,6 @@ class ActionService {
|
|||||||
await _remoteAssetRepository.trash(remoteIds);
|
await _remoteAssetRepository.trash(remoteIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> restoreTrash(List<String> ids) async {
|
|
||||||
await _assetApiRepository.restoreTrash(ids);
|
|
||||||
await _remoteAssetRepository.restoreTrash(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> emptyTrash(String userId) async {
|
Future<int> emptyTrash(String userId) async {
|
||||||
final count = await _assetApiRepository.emptyTrash();
|
final count = await _assetApiRepository.emptyTrash();
|
||||||
await _remoteAssetRepository.emptyTrash(userId);
|
await _remoteAssetRepository.emptyTrash(userId);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/stack.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/restore.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
@@ -21,7 +23,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
@@ -30,7 +31,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
@@ -208,11 +208,7 @@ enum ActionButtonType {
|
|||||||
),
|
),
|
||||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.restoreTrash => RestoreActionButton(
|
ActionButtonType.restoreTrash => ActionMenuItemWidget(action: RestoreAction(assets: [context.asset])),
|
||||||
source: context.source,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
|
||||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||||
source: context.source,
|
source: context.source,
|
||||||
iconOnly: iconOnly,
|
iconOnly: iconOnly,
|
||||||
@@ -248,7 +244,7 @@ enum ActionButtonType {
|
|||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
),
|
),
|
||||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.unstack => ActionMenuItemWidget(action: StackAction(assets: [context.asset])),
|
||||||
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
||||||
remoteId: context.asset.remoteId!,
|
remoteId: context.asset.remoteId!,
|
||||||
origin: context.timelineOrigin,
|
origin: context.timelineOrigin,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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> favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == 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((asset) => asset.visibility == visibility);
|
||||||
|
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) =>
|
||||||
|
remote().where((asset) => asset.visibility != visibility);
|
||||||
|
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
|
||||||
|
remote().where((asset) => asset.isArchived == isArchived);
|
||||||
|
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
|
||||||
|
AssetFilter<RemoteAsset> trashed({bool isTrashed = true}) => remote().where((asset) => asset.isTrashed == isTrashed);
|
||||||
|
|
||||||
|
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||||
|
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
import 'package:openapi/api.dart' show Optional;
|
import 'package:openapi/api.dart' show Optional;
|
||||||
|
|
||||||
sealed class Option<T> {
|
sealed class Option<T> {
|
||||||
@@ -21,6 +22,11 @@ sealed class Option<T> {
|
|||||||
None() => null,
|
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) {
|
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
|
||||||
Some(:final value) => onSome(value),
|
Some(:final value) => onSome(value),
|
||||||
None() => onNone(),
|
None() => onNone(),
|
||||||
@@ -65,3 +71,10 @@ extension OptionToOptional<T> on Option<T> {
|
|||||||
Some(:final value) => Optional.present(value),
|
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 {
|
class SnackbarManager {
|
||||||
const 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 messenger = scaffoldMessengerKey.currentState;
|
||||||
final context = scaffoldMessengerKey.currentContext;
|
final context = scaffoldMessengerKey.currentContext;
|
||||||
if (messenger == null || context == null) {
|
if (messenger == null || context == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
duration ??= const .new(seconds: 4);
|
||||||
messenger.hideCurrentSnackBar();
|
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 theme = Theme.of(context);
|
||||||
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
|
||||||
final (IconData icon, Color background, Color foreground) = switch (type) {
|
final (IconData icon, Color background, Color foreground) = switch (type) {
|
||||||
@@ -29,7 +34,7 @@ class SnackbarManager {
|
|||||||
return SnackBar(
|
return SnackBar(
|
||||||
behavior: .floating,
|
behavior: .floating,
|
||||||
backgroundColor: background,
|
backgroundColor: background,
|
||||||
duration: const .new(seconds: 4),
|
duration: duration,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
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();
|
const snackbar = SnackbarManager();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import 'package:immich_mobile/infrastructure/repositories/local_album.repository
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
@@ -15,6 +15,7 @@ import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.re
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/toast.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ class MockUserRepository extends Mock implements UserRepository {}
|
|||||||
|
|
||||||
class MockPartnerRepository extends Mock implements PartnerRepository {}
|
class MockPartnerRepository extends Mock implements PartnerRepository {}
|
||||||
|
|
||||||
|
class MockToastRepository extends Mock implements ToastRepository {}
|
||||||
|
|
||||||
// API Repos
|
// API Repos
|
||||||
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
class MockUserApiRepository extends Mock implements UserApiRepository {}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ import '../../utils.dart';
|
|||||||
class RemoteAssetFactory {
|
class RemoteAssetFactory {
|
||||||
const 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,
|
||||||
|
DateTime? deletedAt,
|
||||||
|
}) {
|
||||||
id = TestUtils.uuid(id);
|
id = TestUtils.uuid(id);
|
||||||
|
|
||||||
return RemoteAsset(
|
return RemoteAsset(
|
||||||
@@ -17,7 +25,10 @@ class RemoteAssetFactory {
|
|||||||
createdAt: TestUtils.yesterday(),
|
createdAt: TestUtils.yesterday(),
|
||||||
updatedAt: TestUtils.now(),
|
updatedAt: TestUtils.now(),
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
|
visibility: visibility,
|
||||||
|
stackId: stackId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
deletedAt: deletedAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class RepositoryMocks {
|
|||||||
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
|
||||||
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
|
||||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||||
|
final toast = MockToastRepository();
|
||||||
|
|
||||||
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class RepositoryMocks {
|
|||||||
localAsset.reset();
|
localAsset.reset();
|
||||||
reset(trashedAsset);
|
reset(trashedAsset);
|
||||||
nativeApi.reset();
|
nativeApi.reset();
|
||||||
|
reset(toast);
|
||||||
_stubLocalAlbumRepository();
|
_stubLocalAlbumRepository();
|
||||||
_stubLocalAssetRepository();
|
_stubLocalAssetRepository();
|
||||||
_stubNativeSyncApi();
|
_stubNativeSyncApi();
|
||||||
@@ -89,6 +91,9 @@ class ServiceMocks {
|
|||||||
|
|
||||||
void _stubAssetService() {
|
void _stubAssetService() {
|
||||||
when(asset.updateFavorite).thenAnswer((_) async {});
|
when(asset.updateFavorite).thenAnswer((_) async {});
|
||||||
|
when(asset.stack).thenAnswer((_) async {});
|
||||||
|
when(asset.unstack).thenAnswer((_) async {});
|
||||||
|
when(asset.restoreTrash).thenAnswer((_) async {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +172,15 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
|
|||||||
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||||
Future<void> Function() get updateFavorite =>
|
Future<void> Function() get updateFavorite =>
|
||||||
() => service.updateFavorite(any(), any());
|
() => service.updateFavorite(any(), any());
|
||||||
|
|
||||||
|
Future<void> Function() get stack =>
|
||||||
|
() => service.stack(any(), any());
|
||||||
|
|
||||||
|
Future<void> Function() get unstack =>
|
||||||
|
() => service.unstack(any());
|
||||||
|
|
||||||
|
Future<void> Function() get restoreTrash =>
|
||||||
|
() => service.restoreTrash(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
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/favorite.action.dart';
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -68,11 +68,22 @@ void main() {
|
|||||||
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
|
verify(() => assetService.updateFavorite([stale.id], true)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
testWidgets('reports the favorite count through the toast repository', (tester) async {
|
||||||
await tester.pumpTestAction(context, FavoriteAction(assets: [owned()]));
|
final toast = context.repository.toast;
|
||||||
await tester.pumpUntilFound(find.byType(SnackBar));
|
|
||||||
|
|
||||||
expect(find.byType(SnackBar), findsOneWidget);
|
await tester.pumpTestAction(context, FavoriteAction(assets: [owned(), owned()]));
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.favorite_action_prompt(count: 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the unfavorite count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, FavoriteAction(assets: [owned(isFavorite: true), owned(isFavorite: true)]));
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.unfavorite_action_prompt(count: 2));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
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/restore.action.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset owned({bool trashed = true}) =>
|
||||||
|
RemoteAssetFactory.create(ownerId: context.currentUser.id, deletedAt: trashed ? DateTime(2020) : null);
|
||||||
|
|
||||||
|
group('RestoreAction', () {
|
||||||
|
testWidgets('restores the eligible owned trashed assets', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, RestoreAction(assets: [asset]));
|
||||||
|
|
||||||
|
verify(() => assetService.restoreTrash([asset.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||||
|
final mine = owned();
|
||||||
|
final theirs = RemoteAssetFactory.create(deletedAt: DateTime(2020));
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, RestoreAction(assets: [mine, theirs]));
|
||||||
|
|
||||||
|
verify(() => assetService.restoreTrash([mine.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('skips owned assets that are not trashed', (tester) async {
|
||||||
|
final trashed = owned();
|
||||||
|
final live = owned(trashed: false);
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, RestoreAction(assets: [trashed, live]));
|
||||||
|
|
||||||
|
verify(() => assetService.restoreTrash([trashed.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||||
|
final first = owned();
|
||||||
|
final second = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, RestoreAction(assets: [first, second]));
|
||||||
|
|
||||||
|
verify(() => assetService.restoreTrash([first.id, second.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports success through the toast repository with the restored count', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
await tester.pumpTestAction(context, RestoreAction(assets: [owned(), owned()]));
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.assets_restored_count(count: 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
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/stack.action.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../domain/service.mock.dart';
|
||||||
|
import '../../factories/remote_asset_factory.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
RemoteAsset owned({String? stackId}) => RemoteAssetFactory.create(ownerId: context.currentUser.id, stackId: stackId);
|
||||||
|
|
||||||
|
group('StackAction', () {
|
||||||
|
testWidgets('stacks the eligible owned assets', (tester) async {
|
||||||
|
final first = owned();
|
||||||
|
final second = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
||||||
|
|
||||||
|
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('unstacks the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned(stackId: 'stack');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, StackAction(assets: [asset]));
|
||||||
|
|
||||||
|
verify(() => assetService.unstack(['stack'])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('prioritizes stack when mixed state', (tester) async {
|
||||||
|
final first = owned();
|
||||||
|
final second = owned(stackId: 'stack');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
||||||
|
|
||||||
|
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||||
|
final mine = owned();
|
||||||
|
final other = owned();
|
||||||
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, StackAction(assets: [mine, other, theirs]));
|
||||||
|
|
||||||
|
verify(() => assetService.stack(context.currentUser.id, [mine.id, other.id])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('unstacks every selected stack in a single call', (tester) async {
|
||||||
|
final first = owned(stackId: 'stack-1');
|
||||||
|
final second = owned(stackId: 'stack-2');
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, StackAction(assets: [first, second]));
|
||||||
|
|
||||||
|
verify(() => assetService.unstack(['stack-1', 'stack-2'])).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the stacked count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(context, StackAction(assets: [owned(), owned()]));
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.stacked_assets_count(count: 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports the unstacked count through the toast repository', (tester) async {
|
||||||
|
final toast = context.repository.toast;
|
||||||
|
|
||||||
|
await tester.pumpTestAction(
|
||||||
|
context,
|
||||||
|
StackAction(
|
||||||
|
assets: [
|
||||||
|
owned(stackId: 'stack-1'),
|
||||||
|
owned(stackId: 'stack-2'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final message = verify(() => toast.success(captureAny())).captured.single as String;
|
||||||
|
expect(message, StaticTranslations.instance.unstacked_assets_count(count: 2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'
|
|||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.widget.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/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
@@ -43,6 +44,7 @@ class PresentationContext {
|
|||||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
|
||||||
assetServiceProvider.overrideWithValue(service.asset.service),
|
assetServiceProvider.overrideWithValue(service.asset.service),
|
||||||
partnerServiceProvider.overrideWithValue(service.partner.service),
|
partnerServiceProvider.overrideWithValue(service.partner.service),
|
||||||
|
toastRepositoryProvider.overrideWithValue(repository.toast),
|
||||||
];
|
];
|
||||||
|
|
||||||
static Future<PresentationContext> create() async {
|
static Future<PresentationContext> create() async {
|
||||||
|
|||||||
@@ -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]).favorite();
|
||||||
|
|
||||||
|
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]).archived(isArchived: false);
|
||||||
|
|
||||||
|
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]).stacked(isStacked: false);
|
||||||
|
|
||||||
|
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]).favorite(isFavorite: false);
|
||||||
|
|
||||||
|
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).archived(isArchived: false).stacked(isStacked: false);
|
||||||
|
|
||||||
|
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).favorite();
|
||||||
|
|
||||||
|
expect(result.toList(), [favorite]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -53,10 +53,10 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
|||||||
RegionList: {
|
RegionList: {
|
||||||
Area: {
|
Area: {
|
||||||
// (X,Y) // center of the rectangle
|
// (X,Y) // center of the rectangle
|
||||||
X: number;
|
X: number | string;
|
||||||
Y: number;
|
Y: number | string;
|
||||||
W: number;
|
W: number | string;
|
||||||
H: number;
|
H: number | string;
|
||||||
Unit: string;
|
Unit: string;
|
||||||
};
|
};
|
||||||
Rotation?: number;
|
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,
|
Orientation: orientation,
|
||||||
RegionInfo: {
|
RegionInfo: {
|
||||||
AppliedToDimensions: { W: 1000, H: 100, Unit: 'pixel' },
|
AppliedToDimensions: { W: 1000, H: 100, Unit: 'pixel' },
|
||||||
@@ -1371,6 +1374,35 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.person.updateAll).not.toHaveBeenCalled();
|
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 () => {
|
it('should apply metadata face tags creating new people', async () => {
|
||||||
const asset = AssetFactory.create();
|
const asset = AssetFactory.create();
|
||||||
const person = PersonFactory.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
|
// update area coordinates and dimensions in RegionList assuming "normalized" unit as per MWG guidelines
|
||||||
const adjustedRegionList = regionInfo.RegionList.map((region) => {
|
const adjustedRegionList = regionInfo.RegionList.map((region) => {
|
||||||
let { X, Y, W, H } = region.Area;
|
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) {
|
switch (orientation) {
|
||||||
case ExifOrientation.MirrorHorizontal: {
|
case ExifOrientation.MirrorHorizontal: {
|
||||||
X = 1 - X;
|
X = 1 - X;
|
||||||
@@ -926,16 +933,21 @@ export class MetadataService extends BaseService {
|
|||||||
const loweredName = region.Name.toLowerCase();
|
const loweredName = region.Name.toLowerCase();
|
||||||
const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID();
|
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 = {
|
const face = {
|
||||||
id: this.cryptoRepository.randomUUID(),
|
id: this.cryptoRepository.randomUUID(),
|
||||||
personId,
|
personId,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
imageWidth,
|
imageWidth,
|
||||||
imageHeight,
|
imageHeight,
|
||||||
boundingBoxX1: Math.floor((region.Area.X - region.Area.W / 2) * imageWidth),
|
boundingBoxX1: Math.floor((X - W / 2) * imageWidth),
|
||||||
boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight),
|
boundingBoxY1: Math.floor((Y - H / 2) * imageHeight),
|
||||||
boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth),
|
boundingBoxX2: Math.floor((X + W / 2) * imageWidth),
|
||||||
boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight),
|
boundingBoxY2: Math.floor((Y + H / 2) * imageHeight),
|
||||||
sourceType: SourceType.Exif,
|
sourceType: SourceType.Exif,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user