Compare commits

...

19 Commits

Author SHA1 Message Date
shenlong-tanwen 5e952f62e0 feat: delete action 2026-07-08 19:05:55 +05:30
shenlong-tanwen b7f5ab160a feat: album action 2026-07-06 19:00:53 +05:30
shenlong-tanwen 8d82422140 feat: open in browser, similar, set profile action 2026-07-06 17:14:13 +05:30
shenlong-tanwen 0ee96e7304 feat: cast and slideshow action 2026-07-06 17:13:23 +05:30
shenlong-tanwen 3adc49645d feat: lock action 2026-07-06 16:20:39 +05:30
shenlong-tanwen f67629ff07 refactor: base action 2026-07-06 03:35:18 +05:30
shenlong-tanwen 7130553634 refactor: return AssetFilter from asset action filter 2026-07-01 22:29:04 +05:30
shenlong-tanwen d26f6c0665 feat: archive action 2026-07-01 21:40:15 +05:30
shenlong-tanwen da89c75bdd review changes 2026-07-01 21:39:53 +05:30
shenlong-tanwen 2272583a7e feat: stack action
# Conflicts:
#	mobile/lib/domain/services/asset.service.dart
#	mobile/lib/presentation/actions/favorite.action.dart
#	mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart
#	mobile/lib/utils/action_button.utils.dart
#	mobile/test/unit/factories/remote_asset_factory.dart
#	mobile/test/unit/mocks.dart
2026-07-01 21:31:55 +05:30
shenlong-tanwen 73f8e90f0f chore: migrate general bottom sheet favorite 2026-07-01 21:28:04 +05:30
shenlong-tanwen 2999b00e5d feat: restore action 2026-07-01 21:27:49 +05:30
shenlong-tanwen c668bd3342 refactor: feedback repository 2026-07-01 21:22:05 +05:30
shenlong-tanwen c3a02b179a refactor: add asset update method 2026-07-01 20:31:58 +05:30
shenlong-tanwen 6b77c90e1c refactor: owned action assets filter 2026-07-01 20:29:33 +05:30
Padhai_Kaneer df383c1ead fix(server): face region coordinates parsing (#29333) 2026-06-29 17:05:20 +02:00
Santo Shakil af2efda310 fix(mobile): apply exif orientation to android raw photos (#29337) 2026-06-29 10:59:09 -04:00
shenlong 5164feb5b4 chore: disable logs in tests (#29360)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-29 09:43:51 -04:00
shenlong 56eb69b328 chore: cleanup unit tests (#29357)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-29 09:38:00 -04:00
102 changed files with 2778 additions and 2362 deletions
+1
View File
@@ -7,6 +7,7 @@ project(native_buffer LANGUAGES C)
add_library(native_buffer SHARED
src/main/cpp/native_buffer.c
src/main/cpp/native_image.c
)
target_link_libraries(native_buffer jnigraphics)
@@ -0,0 +1,109 @@
#include <jni.h>
#include <stdlib.h>
#include <stdint.h>
#include <android/bitmap.h>
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
#define TILE 32
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
enum {
ORIENTATION_FLIP_HORIZONTAL = 2,
ORIENTATION_ROTATE_180 = 3,
ORIENTATION_FLIP_VERTICAL = 4,
ORIENTATION_TRANSPOSE = 5,
ORIENTATION_ROTATE_90 = 6,
ORIENTATION_TRANSVERSE = 7,
ORIENTATION_ROTATE_270 = 8,
};
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
static int swaps_dims(int o) {
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
}
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
switch (o) {
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
default: *base = 0; *stepX = 1; *stepY = dw; break;
}
}
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
for (int ty = 0; ty < sh; ty += TILE) {
int yEnd = ty + TILE < sh ? ty + TILE : sh;
for (int tx = 0; tx < sw; tx += TILE) {
int xEnd = tx + TILE < sw ? tx + TILE : sw;
for (int sy = ty; sy < yEnd; sy++) {
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
for (int sx = tx; sx < xEnd; sx++) {
dst[idx] = srcRow[sx];
idx += stepX;
}
}
}
}
}
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_NativeImage_rotate(
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
AndroidBitmapInfo info;
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
return 0;
}
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
return 0;
}
int sw = (int) info.width;
int sh = (int) info.height;
int dw = swaps_dims(orientation) ? sh : sw;
int dh = swaps_dims(orientation) ? sw : sh;
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
if (dst == NULL) {
return 0;
}
void *srcPixels = NULL;
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
free(dst);
return 0;
}
int64_t base, stepX, stepY;
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
AndroidBitmap_unlockPixels(env, bitmap);
jint dims[3] = {dw, dh, dw * 4};
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
if ((*env)->ExceptionCheck(env)) {
free(dst);
return 0;
}
return (jlong) dst;
}
@@ -0,0 +1,19 @@
package app.alextran.immich
import android.graphics.Bitmap
object NativeImage {
init {
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
System.loadLibrary("native_buffer")
}
/**
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
* non-8888 config) so the caller can fall back.
*/
@JvmStatic
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
}
@@ -12,7 +12,9 @@ import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeImage
import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors
@@ -181,35 +183,88 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
val id = assetId.toLong()
signal.throwIfCanceled()
val bitmap = if (isVideo) {
decodeVideoThumbnail(id, size, signal)
} else {
decodeImage(id, size, signal)
}
try {
signal.throwIfCanceled()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
val res = if (isVideo) {
decodeVideoThumbnail(id, size, signal).toNativeBuffer()
} else {
val (bitmap, orientation) = decodeImage(id, size, signal)
signal.throwIfCanceled()
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
bitmap.toNativeBuffer()
} else {
rotateToNativeBuffer(bitmap, orientation, signal)
}
}
// Don't re-check cancellation here: res owns a malloc'd buffer, and bailing to CANCELLED would
// orphan it. Deliver it; Dart frees the buffer itself if the request was cancelled meanwhile.
callback(Result.success(res))
} catch (e: Exception) {
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
// Returns the decoded bitmap plus the EXIF orientation that still needs applying. Only Q+ raw
// decodes come back unrotated (ImageDecoder / loadThumbnail skip EXIF for raw like DNG); every
// other path already orients itself, so it reports ORIENTATION_NORMAL.
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Pair<Bitmap, Int> {
signal.throwIfCanceled()
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
val handleRaw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isRawMime(uri)
val orientation = if (handleRaw) rawOrientation(uri) else ExifInterface.ORIENTATION_NORMAL
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
return decodeSource(uri, size, signal)
// A "load original" request is unsized -> a full-res decode (a sized > 768 just samples to target).
return decodeSource(uri, size, signal) to orientation
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.loadThumbnail(uri, size, signal)
} else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
}
return bitmap to orientation
}
private fun isRawMime(uri: Uri): Boolean {
val mime = resolver.getType(uri) ?: return false
return mime.startsWith("image/x-") || mime == "image/dng"
}
private fun rawOrientation(uri: Uri): Int {
return resolver.openInputStream(uri)?.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL
}
// ImageDecoder / loadThumbnail skip EXIF orientation for raw (e.g. DNG) on Q+, so the decoded
// bitmap comes back unrotated. Rotate it into the output buffer in native code (one pass, no
// intermediate rotated bitmap).
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
signal.throwIfCanceled()
// Force ARGB_8888: the native rotate needs a lockable 8888 buffer, and toNativeBuffer() below
// allocates width*height*4 (an F16/HDR decode would otherwise under-allocate). No-op for the
// common already-8888 case.
val src = if (bitmap.config != Bitmap.Config.ARGB_8888) {
val converted = bitmap.copy(Bitmap.Config.ARGB_8888, false)
bitmap.recycle()
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
} else {
bitmap
}
try {
val info = IntArray(3)
val pointer = NativeImage.rotate(src, orientation, info)
if (pointer == 0L) throw IOException("native rotate failed for orientation $orientation")
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
} finally {
if (!src.isRecycled) src.recycle()
}
}
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
-2
View File
@@ -9,8 +9,6 @@ enum SortOrder {
enum TextSearchType { context, filename, description, ocr }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum ActionSource { timeline, viewer }
enum ShareAssetType { original, preview }
@@ -78,6 +78,7 @@ sealed class BaseAsset {
bool get hasLocal => storage == AssetState.local || storage == AssetState.merged;
bool get isLocalOnly => storage == AssetState.local;
bool get isRemoteOnly => storage == AssetState.remote;
bool get isMerged => storage == AssetState.merged;
bool get isEditable => false;
@@ -52,6 +52,12 @@ class RemoteAsset extends BaseAsset {
bool get isTrashed => deletedAt != null;
bool get isStacked => stackId != null;
bool get isArchived => visibility == .archive;
bool get isLocked => visibility == .locked;
@override
String toString() {
return '''Asset {
+53 -3
View File
@@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
class AssetService {
final RemoteAssetRepository _remoteRepository;
@@ -69,12 +70,61 @@ class AssetService {
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
Future<void> restoreTrash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
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);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
await _remoteRepository.update(remoteIds, isFavorite: isFavorite, visibility: visibility);
}
Future<void> trash(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.delete(remoteIds, false);
await _remoteRepository.trash(remoteIds);
}
Future<void> delete(List<String> remoteIds) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.delete(remoteIds, true);
await _remoteRepository.delete(remoteIds);
}
}
@@ -183,6 +183,12 @@ class RemoteAlbumService {
return album.added.length;
}
Future<int> removeAssets({required String albumId, required List<String> assetIds}) async {
final result = await _albumApiRepository.removeAssets(albumId, assetIds);
await _repository.removeAssets(albumId, result.removed);
return result.removed.length;
}
/// !TODO The name here is not clear as we have addAssets method above,
/// which is only add remote assets to album, for the next PR, we will allow
/// adding local assets from album from the timeline as well with this flow.
@@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
@@ -286,4 +287,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()!).get();
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
final companion = RemoteAssetEntityCompanion(
visibility: visibility.toDriftValue(),
isFavorite: isFavorite.toDriftValue(),
);
return _db.batch((batch) {
for (final remoteId in remoteIds) {
batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId));
}
});
}
}
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
@@ -27,13 +28,14 @@ class PartnerPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedByAsync = ref.watch(partnersStateProvider);
final scope = ActionScope.from(context, ref);
return Scaffold(
appBar: AppBar(
title: Text(context.t.partners),
elevation: 0,
centerTitle: false,
actions: const [ActionIconButtonWidget(action: PartnerAddAction())],
actions: [ActionIconButtonWidget(action: PartnerAddAction(scope: scope))],
),
body: sharedByAsync.when(
data: (partners) => PartnerSharedByList(partners: partners.toList(growable: false)),
@@ -44,11 +46,12 @@ class PartnerPage extends ConsumerWidget {
}
}
class _EmptyPartners extends StatelessWidget {
class _EmptyPartners extends ConsumerWidget {
const _EmptyPartners();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final scope = ActionScope.from(context, ref);
return Padding(
padding: const .symmetric(horizontal: 16.0),
child: Column(
@@ -58,9 +61,9 @@ class _EmptyPartners extends StatelessWidget {
padding: const .symmetric(vertical: 8),
child: Text(context.t.partner_page_empty_message, style: const TextStyle(fontSize: 14)),
),
const Align(
Align(
alignment: .center,
child: ActionButtonWidget(action: PartnerAddAction()),
child: ActionButtonWidget(action: PartnerAddAction(scope: scope)),
),
],
),
@@ -69,16 +72,17 @@ class _EmptyPartners extends StatelessWidget {
}
@visibleForTesting
class PartnerSharedByList extends StatelessWidget {
class PartnerSharedByList extends ConsumerWidget {
const PartnerSharedByList({super.key, required this.partners});
final List<Partner> partners;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
if (partners.isEmpty) {
return const _EmptyPartners();
}
final scope = ActionScope.from(context, ref);
return ListView.builder(
itemCount: partners.length,
@@ -89,7 +93,7 @@ class PartnerSharedByList extends StatelessWidget {
title: Text(partner.name),
subtitle: Text(partner.email),
trailing: ActionIconButtonWidget(
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
action: PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
),
);
},
+18 -16
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/user.provider.dart';
class ActionScope {
final BuildContext context;
@@ -9,24 +11,24 @@ class ActionScope {
final UserDto authUser;
const ActionScope({required this.context, required this.ref, required this.authUser});
factory ActionScope.from(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
throw StateError('Auth user is not available in ActionScope');
}
return ActionScope(context: context, ref: ref, authUser: authUser);
}
}
abstract class BaseAction {
const BaseAction();
final ActionScope scope;
final IconData icon;
final String label;
final bool isVisible;
IconData get icon;
const BaseAction({required this.scope, required this.icon, required this.label, this.isVisible = true});
String label(ActionScope scope);
bool isVisible(ActionScope scope) => true;
Future<void> onAction(ActionScope scope);
}
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
final Iterable<BaseAsset> assets;
const AssetAction({required this.assets});
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
Future<void> onAction();
}
@@ -1,16 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/error_handler.dart';
import 'package:immich_ui/immich_ui.dart';
class _ActionWidgetScope {
final IconData icon;
final String label;
final VoidCallback onAction;
final FutureOr<void> Function() onAction;
const _ActionWidgetScope({required this.label, required this.onAction});
const _ActionWidgetScope({required this.icon, required this.label, required this.onAction});
}
class _ActionWidget extends ConsumerWidget {
@@ -19,27 +21,21 @@ class _ActionWidget extends ConsumerWidget {
const _ActionWidget({required this.action, required this.builder});
Future<void> _onAction(ActionScope scope) async {
Future<void> _onAction() async {
try {
await action.onAction(scope);
await action.onAction();
} catch (error, stackTrace) {
handleError(scope.context, error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
handleError(error, stack: stackTrace, description: 'Action failed: ${action.runtimeType}');
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final authUser = ref.watch(currentUserProvider);
if (authUser == null) {
if (!action.isVisible) {
return const SizedBox.shrink();
}
final scope = ActionScope(context: context, ref: ref, authUser: authUser);
if (!action.isVisible(scope)) {
return const SizedBox.shrink();
}
return builder(.new(label: action.label(scope), onAction: () => _onAction(scope)));
return builder(.new(icon: action.icon, label: action.label, onAction: _onAction));
}
}
@@ -52,7 +48,7 @@ class ActionIconButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichIconButton(icon: action.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichIconButton(icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
);
}
@@ -65,8 +61,7 @@ class ActionButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) =>
ImmichTextButton(labelText: ctx.label, icon: action.icon, onPressed: ctx.onAction, variant: variant),
builder: (ctx) => ImmichTextButton(labelText: ctx.label, icon: ctx.icon, onPressed: ctx.onAction, variant: variant),
);
}
@@ -78,7 +73,7 @@ class ActionColumnButtonWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichColumnButton(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichColumnButton(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
@@ -90,6 +85,6 @@ class ActionMenuItemWidget extends StatelessWidget {
@override
Widget build(BuildContext context) => _ActionWidget(
action: action,
builder: (ctx) => ImmichMenuItem(icon: action.icon, label: ctx.label, onPressed: ctx.onAction),
builder: (ctx) => ImmichMenuItem(icon: ctx.icon, label: ctx.label, onPressed: ctx.onAction),
);
}
@@ -0,0 +1,47 @@
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 ArchiveAction extends BaseAction {
final List<String> assetIds;
final bool archive;
const ArchiveAction._({
required this.assetIds,
required this.archive,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory ArchiveAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final archive = ownedAssets.archived(isArchived: false).isNotEmpty;
final assetIds = ownedAssets.archived(isArchived: !archive).map((asset) => asset.id).toList(growable: false);
return ArchiveAction._(
assetIds: assetIds,
archive: archive,
scope: scope,
icon: archive ? Icons.archive_outlined : Icons.unarchive_outlined,
label: archive ? scope.context.t.archive : scope.context.t.unarchive,
isVisible: assetIds.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(archive ? .archive : .timeline));
final message = archive
? context.t.archive_action_prompt(count: assetIds.length)
: context.t.unarchive_action_prompt(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -0,0 +1,38 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.dart';
class AssetActions {
final AssetDebugAction debug;
final FavoriteAction favorite;
final ArchiveAction archive;
final StackAction stack;
final LockAction lock;
final DeleteAction delete;
final CleanupLocalAction cleanup;
const AssetActions({
required this.debug,
required this.favorite,
required this.archive,
required this.stack,
required this.lock,
required this.delete,
required this.cleanup,
});
factory AssetActions.from(ActionScope scope, List<BaseAsset> assets) => .new(
debug: AssetDebugAction(assets: assets, scope: scope),
favorite: FavoriteAction(assets: assets, scope: scope),
archive: ArchiveAction(assets: assets, scope: scope),
stack: StackAction(assets: assets, scope: scope),
lock: LockAction(assets: assets, scope: scope),
delete: DeleteAction(assets: assets, scope: scope),
cleanup: CleanupLocalAction(assets: assets, scope: scope),
);
}
@@ -8,20 +8,18 @@ import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
class AssetDebugAction extends BaseAction {
final List<BaseAsset> asset;
AssetDebugAction._({this.asset = const [], required super.scope, super.isVisible})
: super(icon: Icons.help_outline_rounded, label: scope.context.t.troubleshoot);
factory AssetDebugAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => AssetDebugAction._(
asset: assets.toList(growable: false),
scope: scope,
isVisible: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting) && assets.length == 1,
);
@override
IconData get icon => Icons.help_outline_rounded;
@override
String label(ActionScope scope) => scope.context.t.troubleshoot;
@override
bool isVisible(ActionScope scope) =>
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
@override
Future<void> onAction(ActionScope scope) async =>
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
Future<void> onAction() async => unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: asset.first)));
}
@@ -0,0 +1,25 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastAction extends BaseAction {
const CastAction._({required super.scope, required super.icon, required super.label});
factory CastAction({required ActionScope scope}) {
final casting = scope.ref.watch(castProvider.select((state) => state.isCasting));
return CastAction._(
scope: scope,
icon: casting ? Icons.cast_connected_rounded : Icons.cast_rounded,
label: scope.context.t.cast,
);
}
@override
Future<void> onAction() async {
unawaited(showDialog(context: scope.context, builder: (_) => const CastDialog()));
}
}
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.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/store.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class DeleteAction extends BaseAction {
final List<String> localIds;
final List<String> remoteIds;
final bool trash;
DeleteAction._({
required this.localIds,
required this.remoteIds,
required super.scope,
required this.trash,
super.isVisible,
}) : super(icon: Icons.delete_outline, label: trash ? scope.context.t.trash : scope.context.t.delete);
factory DeleteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ActionScope(:ref) = scope;
final localIds = <String>[];
final ownedRemote = <RemoteAsset>[];
for (final asset in assets) {
if (asset.localId case final id?) {
localIds.add(id);
}
if (asset case final RemoteAsset remote when remote.ownerId == scope.authUser.id) {
ownedRemote.add(remote);
}
}
final remoteIds = ownedRemote.map((asset) => asset.id).toList(growable: false);
final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
// Assets already in trash or in locked page should be permanently deleted irrespective of the trash feature being enabled
final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked);
return ._(
localIds: localIds,
remoteIds: remoteIds,
trash: trash,
scope: scope,
isVisible: remoteIds.isNotEmpty || localIds.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final toast = ref.read(toastRepositoryProvider);
// Local-only
// Single prompt on iOS & Android (without MANAGE_MEDIA)
// No prompt on Android (with MANAGE_MEDIA)
if (remoteIds.isEmpty) {
if (localIds.isEmpty) {
return;
}
final count = await cleanupLocalAssets(assetIds: localIds, scope: scope);
if (!context.mounted || count <= 0) {
return;
}
toast.success(context.t.cleanup_deleted_assets(count: count));
return;
}
// Trash
// No prompt on Android (with MANAGE_MEDIA)
// Single prompt on iOS & Android (without MANAGE_MEDIA)
// TODO(shenlong): Handle the native prompt response and skip deleting trash when user cancels the prompt
if (trash) {
if (localIds.isNotEmpty) {
await cleanupLocalAssets(assetIds: localIds, scope: scope);
if (!context.mounted) {
return;
}
}
await ref.read(assetServiceProvider).trash(remoteIds);
toast.success(context.t.trash_action_prompt(count: remoteIds.length));
return;
}
// Permanent delete
// Single prompt on Android (with MANAGE_MEDIA)
// Double prompts on iOS & Android (without MANAGE_MEDIA)
final confirmed = await showDialog<bool>(
context: context,
builder: (_) =>
const ConfirmDialog(title: 'delete_dialog_title', content: 'delete_dialog_alert', ok: 'delete_permanently'),
);
if (confirmed != true || !context.mounted) {
return;
}
// Perform server deletion first so we don't remove the only local copy if the server delete fails
await ref.read(assetServiceProvider).delete(remoteIds);
if (localIds.isNotEmpty && context.mounted) {
await cleanupLocalAssets(assetIds: localIds, scope: scope, requestPrompt: false);
}
if (!context.mounted) {
return;
}
toast.success(context.t.delete_permanently_action_prompt(count: remoteIds.length));
}
}
class CleanupLocalAction extends BaseAction {
final List<String> assetIds;
CleanupLocalAction._({required this.assetIds, required super.scope})
: super(
icon: Icons.no_cell_outlined,
label: scope.context.t.control_bottom_app_bar_delete_from_local,
isVisible: assetIds.isNotEmpty,
);
factory CleanupLocalAction({required Iterable<BaseAsset> assets, required ActionScope scope}) => ._(
assetIds: AssetFilter(assets).backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false),
scope: scope,
);
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final count = await cleanupLocalAssets(assetIds: assetIds, scope: scope);
if (!context.mounted || count <= 0) {
return;
}
ref.read(toastRepositoryProvider).success(context.t.cleanup_deleted_assets(count: count));
}
}
@visibleForTesting
Future<int> cleanupLocalAssets({
required List<String> assetIds,
required ActionScope scope,
bool requestPrompt = true,
}) async {
final ActionScope(:ref, :context) = scope;
/// OS prompts on iOS & Android (without MANAGE_MEDIA)
/// Custom prompt on Android (with MANAGE_MEDIA)
final requireUserPrompt =
requestPrompt && CurrentPlatform.isAndroid && ref.read(storeServiceProvider).get(.manageLocalMediaAndroid, false);
if (requireUserPrompt) {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => ConfirmDialog(
title: context.t.move_to_device_trash,
content: context.t.free_up_space_description,
ok: context.t.ok,
),
);
if (confirmed != true) {
return 0;
}
}
if (!context.mounted) {
return 0;
}
return await ref.read(cleanupServiceProvider).deleteLocalAssets(assetIds);
}
@@ -3,38 +3,45 @@ 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_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> {
final bool shouldFavorite;
class FavoriteAction extends BaseAction {
final List<String> assetIds;
final bool favorite;
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
const FavoriteAction._({
required this.assetIds,
required this.favorite,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory FavoriteAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final favorite = ownedAssets.favorite(isFavorite: false).isNotEmpty;
final assetIds = ownedAssets.favorite(isFavorite: !favorite).map((asset) => asset.id).toList(growable: false);
return FavoriteAction._(
assetIds: assetIds,
favorite: favorite,
scope: scope,
icon: favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
label: favorite ? scope.context.t.favorite : scope.context.t.unfavorite,
isVisible: assetIds.isNotEmpty,
);
}
@override
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
@override
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@override
Future<void> onAction(ActionScope scope) async {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
final message = shouldFavorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);
await ref.read(assetServiceProvider).update(assetIds, isFavorite: .some(favorite));
final message = favorite
? context.t.favorite_action_prompt(count: assetIds.length)
: context.t.unfavorite_action_prompt(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -0,0 +1,47 @@
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 LockAction extends BaseAction {
final List<String> assetIds;
final bool lock;
const LockAction._({
required this.assetIds,
required this.lock,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory LockAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
final lock = ownedAssets.locked(isLocked: false).isNotEmpty;
final assetIds = ownedAssets.locked(isLocked: !lock).map((asset) => asset.id).toList(growable: false);
return LockAction._(
assetIds: assetIds,
lock: lock,
scope: scope,
icon: lock ? Icons.lock_rounded : Icons.lock_open_rounded,
label: lock ? scope.context.t.move_to_locked_folder : scope.context.t.remove_from_locked_folder,
isVisible: assetIds.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).update(assetIds, visibility: .some(lock ? .locked : .timeline));
final message = lock
? scope.context.t.move_to_lock_folder_action_prompt(count: assetIds.length)
: scope.context.t.remove_from_lock_folder_action_prompt(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserAction extends BaseAction {
final String remoteId;
final TimelineOrigin origin;
OpenInBrowserAction({required this.remoteId, required this.origin, required super.scope})
: super(icon: Icons.open_in_browser, label: scope.context.t.open_in_browser);
@override
Future<void> onAction() async {
final serverEndpoint = Store.get(.serverEndpoint).replaceFirst('/api', '');
final originPath = switch (origin) {
.favorite => '/favorites',
.trash => '/trash',
.archive => '/archive',
_ => '',
};
final url = Uri.parse('$serverEndpoint$originPath/photos/$remoteId');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: .externalApplication);
}
}
}
@@ -9,16 +9,10 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class PartnerAddAction extends BaseAction {
const PartnerAddAction();
PartnerAddAction({required super.scope}) : super(icon: Icons.person_add_rounded, label: scope.context.t.add_partner);
@override
IconData get icon => Icons.person_add_rounded;
@override
String label(ActionScope scope) => scope.context.t.add_partner;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
final selected = await showDialog<User>(context: context, builder: (_) => const PartnerSelectionDialog());
if (selected == null) {
@@ -30,19 +24,14 @@ class PartnerAddAction extends BaseAction {
}
class PartnerRemoveAction extends BaseAction {
const PartnerRemoveAction({required this.sharedWithId, required this.partnerName});
PartnerRemoveAction({required super.scope, required this.sharedWithId, required this.partnerName})
: super(icon: Icons.person_remove_rounded, label: scope.context.t.remove);
final String sharedWithId;
final String partnerName;
@override
IconData get icon => Icons.person_remove_rounded;
@override
String label(ActionScope scope) => scope.context.t.remove;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
final ActionScope(:context, :ref, :authUser) = scope;
final confirmed = await showDialog<bool>(
@@ -0,0 +1,33 @@
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/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class RemoveFromAlbumAction extends BaseAction {
final String albumId;
final List<String> assetIds;
RemoveFromAlbumAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
: super(icon: Icons.remove_circle_outline, label: scope.context.t.remove_from_album);
factory RemoveFromAlbumAction({
required Iterable<BaseAsset> assets,
required String albumId,
required ActionScope scope,
}) {
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
return RemoveFromAlbumAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.isNotEmpty);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
final count = await ref.read(remoteAlbumServiceProvider).removeAssets(albumId: albumId, assetIds: assetIds);
ref.read(toastRepositoryProvider).success(context.t.remove_from_album_action_prompt(count: count));
}
}
@@ -0,0 +1,29 @@
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 BaseAction {
final List<String> assetIds;
RestoreAction._({required super.scope, required this.assetIds, super.isVisible})
: super(icon: Icons.history_rounded, label: scope.context.t.restore);
factory RestoreAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final assetIds = AssetFilter(
assets,
).owned(scope.authUser.id).trashed().map((asset) => asset.id).toList(growable: false);
return RestoreAction._(assetIds: assetIds, scope: scope, isVisible: assetIds.isNotEmpty);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(assetServiceProvider).restoreTrash(assetIds);
ref.read(toastRepositoryProvider).success(context.t.assets_restored_count(count: assetIds.length));
}
}
@@ -0,0 +1,32 @@
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/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
class SetAlbumCoverAction extends BaseAction {
final String albumId;
final List<String> assetIds;
SetAlbumCoverAction._({required super.scope, required this.albumId, required this.assetIds, super.isVisible})
: super(icon: Icons.image_outlined, label: scope.context.t.set_as_album_cover);
factory SetAlbumCoverAction({
required Iterable<BaseAsset> assets,
required String albumId,
required ActionScope scope,
}) {
final assetIds = AssetFilter(assets).map((asset) => asset.remoteId).nonNulls.toList(growable: false);
return SetAlbumCoverAction._(scope: scope, albumId: albumId, assetIds: assetIds, isVisible: assetIds.length == 1);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
await ref.read(remoteAlbumServiceProvider).updateAlbum(albumId, thumbnailAssetId: assetIds.first);
ref.read(toastRepositoryProvider).success(context.t.album_cover_updated);
}
}
@@ -0,0 +1,18 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
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/routing/router.dart';
class SetProfilePictureAction extends BaseAction {
final BaseAsset asset;
SetProfilePictureAction({required this.asset, required super.scope})
: super(icon: Icons.account_circle_outlined, label: scope.context.t.set_as_profile_picture);
@override
Future<void> onAction() async => unawaited(scope.context.pushRoute(ProfilePictureCropRoute(asset: asset)));
}
@@ -2,26 +2,23 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.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/routing/router.dart';
class SimilarPhotosActionButton extends ConsumerWidget {
class SimilarPhotosAction extends BaseAction {
final String assetId;
final bool iconOnly;
final bool menuItem;
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
SimilarPhotosAction({required this.assetId, required super.scope})
: super(icon: Icons.compare, label: scope.context.t.view_similar_photos);
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
ref.invalidate(assetViewerProvider);
ref.invalidate(paginatedSearchProvider);
@@ -43,16 +40,4 @@ class SimilarPhotosActionButton extends ConsumerWidget {
unawaited(context.navigateTo(const DriftSearchRoute()));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.compare,
label: "view_similar_photos".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -0,0 +1,18 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowAction extends BaseAction {
SlideshowAction({required super.scope}) : super(icon: Icons.slideshow, label: scope.context.t.slideshow);
@override
Future<void> onAction() async {
final ActionScope(:context, :ref) = scope;
unawaited(context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))));
}
}
@@ -0,0 +1,56 @@
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 BaseAction {
final List<String> assetIds;
final List<String> stackIds;
final bool stack;
const StackAction._({
required this.assetIds,
required this.stackIds,
required this.stack,
required super.scope,
required super.icon,
required super.label,
super.isVisible,
});
factory StackAction({required Iterable<BaseAsset> assets, required ActionScope scope}) {
final ownedAssets = AssetFilter(assets).owned(scope.authUser.id);
// Stack when any owned asset is not yet stacked; otherwise unstack them all.
final stack = ownedAssets.stacked(isStacked: false).isNotEmpty;
final assetIds = ownedAssets.map((asset) => asset.id).toList(growable: false);
final stackIds = ownedAssets.map((asset) => asset.stackId).nonNulls.toList(growable: false);
return StackAction._(
assetIds: assetIds,
stackIds: stackIds,
stack: stack,
scope: scope,
icon: stack ? Icons.filter_none_rounded : Icons.layers_clear_outlined,
label: stack ? scope.context.t.stack : scope.context.t.unstack,
isVisible: stack ? assetIds.length > 1 : stackIds.isNotEmpty,
);
}
@override
Future<void> onAction() async {
final ActionScope(:ref, :context) = scope;
if (stack) {
await ref.read(assetServiceProvider).stack(scope.authUser.id, assetIds);
} else {
await ref.read(assetServiceProvider).unstack(stackIds);
}
final message = stack
? context.t.stacked_assets_count(count: assetIds.length)
: context.t.unstacked_assets_count(count: assetIds.length);
ref.read(toastRepositoryProvider).success(message);
}
}
@@ -1,24 +1,15 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TimelineAction extends BaseAction {
final BaseAction action;
const TimelineAction({required this.action});
TimelineAction({required this.action})
: super(scope: action.scope, icon: action.icon, label: action.label, isVisible: action.isVisible);
@override
IconData get icon => action.icon;
@override
String label(ActionScope scope) => action.label(scope);
@override
bool isVisible(ActionScope scope) => action.isVisible(scope);
@override
Future<void> onAction(ActionScope scope) async {
await action.onAction(scope);
Future<void> onAction() async {
await action.onAction();
scope.ref.read(multiSelectProvider.notifier).reset();
}
}
@@ -1,26 +1,25 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.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/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
enum AddToMenuItem { album }
class AddActionButton extends ConsumerStatefulWidget {
const AddActionButton({super.key, this.originalTheme});
@@ -37,15 +36,6 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
case AddToMenuItem.album:
_openAlbumSelector();
break;
case AddToMenuItem.archive:
performArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.unarchive:
performUnArchiveAction(context, ref, source: ActionSource.viewer);
break;
case AddToMenuItem.lockedFolder:
performMoveToLockFolderAction(context, ref, source: ActionSource.viewer);
break;
}
}
@@ -57,11 +47,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final user = ref.read(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final hasRemote = asset is RemoteAsset;
final showArchive = isOwner && !isInLockedView && hasRemote && !isArchived;
final showUnarchive = isOwner && !isInLockedView && hasRemote && isArchived;
final scope = ActionScope.from(context, ref);
return [
Padding(
@@ -81,25 +67,11 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
),
if (showArchive)
BaseActionButton(
iconData: Icons.archive_outlined,
label: "archive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.archive),
),
if (showUnarchive)
BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.unarchive),
),
BaseActionButton(
iconData: Icons.lock_outline,
label: "locked_folder".tr(),
menuItem: true,
onPressed: () => _handleMenuSelection(AddToMenuItem.lockedFolder),
ActionMenuItemWidget(
action: ArchiveAction(assets: [asset], scope: scope),
),
ActionMenuItemWidget(
action: LockAction(assets: [asset], scope: scope),
),
],
];
@@ -184,7 +156,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
final themeData = widget.originalTheme ?? context.themeData;
return MenuAnchor(
return ImmichMenu(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
@@ -195,7 +167,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: widget.originalTheme != null
children: widget.originalTheme != null
? [
Theme(
data: widget.originalTheme!,
@@ -1,59 +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';
// used to allow performing archive action from different sources (without duplicating code)
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_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,
);
}
}
class ArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.archive_outlined,
label: "to_archive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,30 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/cast.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
class CastActionButton extends ConsumerWidget {
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
final bool iconOnly;
final bool menuItem;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
return BaseActionButton(
iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
iconColor: isCasting ? context.primaryColor : null, // null = default color
label: "cast".t(context: context),
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
iconOnly: iconOnly,
menuItem: menuItem,
);
}
}
@@ -1,92 +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/build_context_extensions.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';
/// This delete action has the following behavior:
/// - Set the deletedAt information, put the asset in the trash in the server
/// which will be permanently deleted after the number of days configure by the admin
/// - Prompt to delete the asset locally
class DeleteActionButton extends ConsumerWidget {
final ActionSource source;
final bool showConfirmation;
final bool iconOnly;
final bool menuItem;
const DeleteActionButton({
super.key,
required this.source,
this.showConfirmation = false,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (showConfirmation) {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('delete'.t(context: context)),
content: Text('delete_action_confirmation_message'.t(context: context)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(
'confirm'.t(context: context),
style: TextStyle(color: context.colorScheme.error),
),
),
],
),
);
if (confirm != true) {
return;
}
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_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(
maxWidth: 110.0,
iconData: Icons.delete_sweep_outlined,
label: "delete".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,68 +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';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
/// This delete action has the following behavior:
/// - Prompt to delete the asset locally
class DeleteLocalActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const DeleteLocalActionButton({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).deleteLocal(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
if (result.count == 0) {
return;
}
ref.invalidate(localAlbumProvider);
final successMessage = 'delete_local_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(
maxWidth: 95.0,
iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,80 +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/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
/// - Delete permanently on the server
/// - Prompt to delete the asset locally
class DeletePermanentActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
final bool useShortLabel;
const DeletePermanentActionButton({
super.key,
required this.source,
this.iconOnly = false,
this.menuItem = false,
this.useShortLabel = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
final confirm =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: count),
) ??
false;
if (!confirm) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_permanently_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(
maxWidth: 110.0,
iconData: Icons.delete_forever,
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,67 +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/asset_grid/permanent_delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior:
/// - Delete permanently on the server
/// - Prompt to delete the asset locally
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class DeleteTrashActionButton extends ConsumerWidget {
final ActionSource source;
const DeleteTrashActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final selectCount = ref.watch(multiSelectProvider.select((s) => s.selectedAssets.length));
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {
return;
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'assets_permanently_deleted_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: Icon(Icons.delete_forever, color: Colors.red[400]),
label: Text(
"delete".t(context: context),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
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 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,63 +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';
// Reusable helper: move to locked folder from any source (e.g called from menu)
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_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,
);
}
}
class MoveToLockFolderActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performMoveToLockFolderAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.lock_outline_rounded,
label: "move_to_locked_folder".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,58 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:url_launcher/url_launcher.dart';
class OpenInBrowserActionButton extends ConsumerWidget {
final String remoteId;
final TimelineOrigin origin;
final bool iconOnly;
final bool menuItem;
const OpenInBrowserActionButton({
super.key,
required this.remoteId,
required this.origin,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap() async {
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
String originPath = '';
switch (origin) {
case TimelineOrigin.favorite:
originPath = '/favorites';
break;
case TimelineOrigin.trash:
originPath = '/trash';
break;
case TimelineOrigin.archive:
originPath = '/archive';
break;
default:
break;
}
final url = '$serverEndpoint$originPath/photos/$remoteId';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
label: 'open_in_browser'.t(context: context),
iconData: Icons.open_in_browser,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: _onTap,
);
}
}
@@ -1,65 +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 RemoveFromAlbumActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromAlbumActionButton({
super.key,
required this.albumId,
required this.source,
this.iconOnly = false,
this.menuItem = false,
});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_album_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.remove_circle_outline,
label: "remove_from_album".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -1,57 +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 RemoveFromLockFolderActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const RemoveFromLockFolderActionButton({
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).removeFromLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_lock_folder_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(
maxWidth: 100.0,
iconData: Icons.lock_open_rounded,
label: "remove_from_locked_folder".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,
);
}
}
@@ -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,56 +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 SetAlbumCoverActionButton extends ConsumerWidget {
final String albumId;
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const SetAlbumCoverActionButton({
super.key,
required this.albumId,
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).setAlbumCover(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'album_cover_updated'.t(context: context);
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.image_outlined,
label: 'set_as_album_cover'.t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -1,35 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/routing/router.dart';
class SetProfilePictureActionButton extends ConsumerWidget {
final BaseAsset asset;
final bool iconOnly;
final bool menuItem;
const SetProfilePictureActionButton({super.key, required this.asset, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context) {
if (!context.mounted) {
return;
}
context.pushRoute(ProfilePictureCropRoute(asset: asset));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.account_circle_outlined,
label: "set_as_profile_picture".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context),
maxWidth: 100,
);
}
}
@@ -1,34 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/timeline.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class SlideshowActionButton extends ConsumerWidget {
final bool iconOnly;
final bool menuItem;
const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) {
if (!context.mounted) {
return;
}
context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider)));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.slideshow,
label: "slideshow".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
maxWidth: 100,
);
}
}
@@ -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,58 +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';
/// This delete action has the following behavior:
/// - Set the deletedAt information, put the asset in the trash in the server
/// which will be permanently deleted after the number of days configure by the admin
class TrashActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_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(
maxWidth: 85.0,
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -1,61 +0,0 @@
// dart
// File: `lib/presentation/widgets/action_buttons/unarchive_action_button.widget.dart`
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';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
// used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) {
return;
}
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_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,
);
}
}
class UnArchiveActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
await performUnArchiveAction(context, ref, source: source);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
iconData: Icons.unarchive_outlined,
label: "unarchive".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
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),
);
}
}
@@ -1,15 +1,14 @@
import 'package:flutter/material.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/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.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/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_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/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/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_toggle_button.widget.dart';
@@ -18,7 +17,6 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
@@ -33,8 +31,6 @@ class ViewerBottomBar extends ConsumerWidget {
}
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final isInLockedView = ref.watch(inLockedViewProvider);
final serverInfo = ref.watch(serverInfoProvider);
@@ -42,11 +38,13 @@ class ViewerBottomBar extends ConsumerWidget {
final originalTheme = context.themeData;
final assets = [asset];
final scope = ActionScope.from(context, ref);
final restore = RestoreAction(assets: assets, scope: scope);
final delete = DeleteAction(assets: assets, scope: scope);
final actions = <Widget>[
if (isInTrash && isOwner && asset.hasRemote)
const RestoreActionButton(source: ActionSource.viewer)
else
const ShareActionButton(source: ActionSource.viewer),
if (restore.isVisible) ActionColumnButtonWidget(action: restore),
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (!isInTrash) ...[
@@ -56,14 +54,8 @@ class ViewerBottomBar extends ConsumerWidget {
const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
],
if (isOwner) ...[
if (asset.isLocalOnly)
const DeleteLocalActionButton(source: ActionSource.viewer)
else if (asset.isTrashed)
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
else
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
if (delete.isVisible) ActionColumnButtonWidget(action: delete),
],
];
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return ImmichMenu(
consumeOutsideTap: true,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
@@ -44,6 +45,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final assetForAction = [asset];
final scope = ActionScope.from(context, ref);
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -61,7 +63,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
ActionIconButtonWidget(
action: FavoriteAction(assets: assetForAction, scope: scope),
),
ViewerKebabMenu(originalTheme: originalTheme),
];
@@ -3,25 +3,18 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_actions.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_permanent_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_location_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_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/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/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -50,7 +43,6 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
@@ -76,8 +68,8 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
@@ -88,19 +80,18 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(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),
],
slivers: [
const AddToAlbumHeader(),
@@ -4,25 +4,18 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.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/actions/action.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/asset_actions.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/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/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_location_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_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/unstack_action_button.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/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -32,7 +25,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
final selectedAssets = multiselect.selectedAssets;
@@ -67,8 +59,8 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
initialChildSize: 0.4,
@@ -78,19 +70,18 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ArchiveActionButton(source: ActionSource.timeline),
...[
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(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),
],
slivers: multiselect.hasRemote
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
@@ -3,30 +3,21 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/actions/action.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_actions.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/bulk_tag_assets_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_permanent_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_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/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/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/unstack_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/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -55,7 +46,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
@@ -83,8 +73,8 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [AssetDebugAction(assets: assets)];
final scope = ActionScope.from(context, ref);
final actions = AssetActions.from(scope, multiselect.selectedAssets.toList(growable: false));
return BaseBottomSheet(
controller: sheetController,
@@ -93,26 +83,23 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.debug,
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (tagsEnabled) const BulkTagAssetsActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(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 DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: [
@@ -3,12 +3,16 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.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/album/album_selector.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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class LocalAlbumBottomSheet extends ConsumerStatefulWidget {
@@ -59,15 +63,22 @@ class _LocalAlbumBottomSheetState extends ConsumerState<LocalAlbumBottomSheet> {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final scope = ActionScope.from(context, ref);
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
final actions = AssetActions.from(scope, assets);
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: const [
ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline),
UploadActionButton(source: ActionSource.timeline),
actions: [
const ShareActionButton(source: ActionSource.timeline),
...[
actions.delete,
actions.cleanup,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const UploadActionButton(source: ActionSource.timeline),
],
slivers: [
const AddToAlbumHeader(),
@@ -1,26 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_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/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class LockedFolderBottomSheet extends ConsumerWidget {
const LockedFolderBottomSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const BaseBottomSheet(
final scope = ActionScope.from(context, ref);
final assets = ref.watch(multiSelectProvider).selectedAssets.toList(growable: false);
final actions = AssetActions.from(scope, assets);
return BaseBottomSheet(
initialChildSize: 0.25,
maxChildSize: 0.4,
shouldCloseOnMinExtent: false,
actions: [
ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline),
DeletePermanentActionButton(source: ActionSource.timeline),
RemoveFromLockFolderActionButton(source: ActionSource.timeline),
const ShareActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
...[
actions.delete,
LockAction(assets: assets, scope: scope),
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
);
}
@@ -3,27 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.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/asset_actions.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.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/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/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_location_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/remove_from_album_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/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/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/unstack_action_button.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/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/server_info.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';
@@ -54,7 +47,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
@override
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
Future<void> addToAlbum(RemoteAlbum album) async {
@@ -85,8 +77,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
final scope = ActionScope.from(context, ref);
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
final actions = AssetActions.from(scope, assets);
return BaseBottomSheet(
controller: sheetController,
@@ -100,25 +93,33 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
const ShareLinkActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
...[
actions.favorite,
actions.archive,
actions.delete,
actions.cleanup,
actions.stack,
actions.lock,
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(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 (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum && multiselect.selectedAssets.length == 1)
SetAlbumCoverActionButton(source: ActionSource.timeline, albumId: widget.album.id),
if (ownsAlbum)
ActionColumnButtonWidget(
action: TimelineAction(
action: RemoveFromAlbumAction(assets: assets, albumId: widget.album.id, scope: scope),
),
),
if (ownsAlbum)
ActionColumnButtonWidget(
action: TimelineAction(
action: SetAlbumCoverAction(assets: assets, albumId: widget.album.id, scope: scope),
),
),
],
slivers: ownsAlbum
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand)]
@@ -1,28 +1,34 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.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/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class TrashBottomBar extends ConsumerWidget {
const TrashBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = ref.watch(multiSelectProvider.select((s) => s.selectedAssets)).toList(growable: false);
final scope = ActionScope.from(context, ref);
return Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.themeData.canvasColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: const SafeArea(
child: SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
DeleteAction(assets: assets, scope: scope),
RestoreAction(assets: assets, scope: scope),
].map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))).toList(growable: false),
),
),
),
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -8,20 +7,17 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
@@ -49,7 +45,6 @@ class ActionNotifier extends Notifier<void> {
late ActionService _service;
late ForegroundUploadService _foregroundUploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -57,7 +52,6 @@ class ActionNotifier extends Notifier<void> {
void build() {
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
@@ -110,21 +104,6 @@ class ActionNotifier extends Notifier<void> {
return _getAssets(source).whereType<RemoteAsset>().ownedAssets(ownerId).toIds().toList(growable: false);
}
List<RemoteAsset> _getOwnedRemoteAssetsForSource(ActionSource source) {
final ownerId = ref.read(currentUserProvider)?.id;
return _getIdsForSource<RemoteAsset>(source).ownedAssets(ownerId).toList();
}
Iterable<T> _getIdsForSource<T extends BaseAsset>(ActionSource source) {
final Set<BaseAsset> assets = _getAssets(source);
return switch (T) {
const (RemoteAsset) => assets.whereType<RemoteAsset>(),
const (LocalAsset) => assets.whereType<LocalAsset>(),
_ => const [],
}
as Iterable<T>;
}
Set<BaseAsset> _getAssets(ActionSource source) {
return switch (source) {
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
@@ -135,16 +114,6 @@ class ActionNotifier extends Notifier<void> {
};
}
Future<ActionResult> troubleshoot(ActionSource source, BuildContext context) async {
final assets = _getAssets(source);
if (assets.length > 1) {
return ActionResult(count: assets.length, success: false, error: 'Cannot troubleshoot multiple assets');
}
unawaited(context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
return ActionResult(count: assets.length, success: true);
}
Future<ActionResult> shareLink(ActionSource source, BuildContext context) async {
final ids = _getRemoteIdsForSource(source);
try {
@@ -156,96 +125,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 {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.archive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to archive assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unArchive(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.unArchive(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unarchive assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> moveToLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
try {
await _service.moveToLockFolder(ids, localIds);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to move assets to lock folder', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> removeFromLockFolder(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.removeFromLockFolder(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to remove assets from lock folder', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> trash(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.trash(ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to trash assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
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 {
try {
final count = await _service.emptyTrash(userId);
@@ -427,33 +306,6 @@ class ActionNotifier extends Notifier<void> {
);
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {
final removedCount = await _service.removeFromAlbum(ids, albumId);
return ActionResult(count: removedCount, success: true);
} catch (error, stack) {
_logger.severe('Failed to remove assets from album', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> setAlbumCover(ActionSource source, String albumId) async {
final assets = _getAssets(source);
final asset = assets.first;
if (asset is! RemoteAsset) {
return const ActionResult(count: 1, success: false, error: 'Asset must be remote');
}
try {
await _service.setAlbumCover(albumId, asset.id);
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to set album cover', error, stack);
return ActionResult(count: 1, success: false, error: error.toString());
}
}
Future<ActionResult> updateDescription(ActionSource source, String description) async {
final ids = _getRemoteIdsForSource(source);
if (ids.length != 1) {
@@ -486,35 +338,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> stack(String userId, ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
await _service.stack(userId, ids);
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to stack assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> unStack(ActionSource source) async {
final assets = _getOwnedRemoteAssetsForSource(source);
try {
await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList());
if (source == ActionSource.viewer) {
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
if (updatedParent != null) {
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
}
}
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to unstack assets', error, stack);
return ActionResult(count: assets.length, success: false);
}
}
Future<ActionResult> shareAssets(
ActionSource source,
BuildContext context, {
@@ -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 =>
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 onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
@@ -1,12 +1,14 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction;
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -41,14 +43,10 @@ class AssetApiRepository extends ApiRepository {
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
Future<void> updateVisibility(List<String> ids, AssetVisibility visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility))));
}
Future<void> updateFavorite(List<String> ids, bool isFavorite) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, isFavorite: Optional.present(isFavorite)));
}
Future<void> updateLocation(List<String> ids, LatLng location) async {
return _api.updateAssets(
AssetBulkUpdateDto(
@@ -77,11 +75,11 @@ class AssetApiRepository extends ApiRepository {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
AssetVisibilityEnum.timeline => AssetVisibility.timeline,
AssetVisibilityEnum.hidden => AssetVisibility.hidden,
AssetVisibilityEnum.locked => AssetVisibility.locked,
AssetVisibilityEnum.archive => AssetVisibility.archive,
api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) {
AssetVisibility.timeline => api.AssetVisibility.timeline,
AssetVisibility.hidden => api.AssetVisibility.hidden,
AssetVisibility.locked => api.AssetVisibility.locked,
AssetVisibility.archive => api.AssetVisibility.archive,
};
Future<String?> getAssetMIMEType(String assetId) async {
@@ -106,6 +104,20 @@ class AssetApiRepository extends ApiRepository {
Future<void> removeEdits(String assetId) async {
return _api.removeAssetEdits(assetId);
}
Future<void> update(
List<String> remoteIds, {
Option<bool> isFavorite = const .none(),
Option<AssetVisibility> visibility = const .none(),
}) {
return _api.updateAssets(
AssetBulkUpdateDto(
ids: remoteIds,
isFavorite: isFavorite.toOptional(),
visibility: visibility.map(_mapVisibility).toOptional(),
),
);
}
}
extension on StackResponseDto {
@@ -0,0 +1,26 @@
import 'dart:async';
import 'package:immich_ui/immich_ui.dart';
class ToastOption {
final Duration? timeout;
final FutureOr<void> Function()? onUndo;
const ToastOption({this.timeout, this.onUndo});
}
class ToastRepository {
const ToastRepository();
FutureOr<void> success(String message, {ToastOption? toast}) {
snackbar.success(message, duration: toast?.timeout);
}
FutureOr<void> info(String message, {ToastOption? toast}) {
snackbar.info(message, duration: toast?.timeout);
}
FutureOr<void> error(String message, {ToastOption? toast}) {
snackbar.error(message, duration: toast?.timeout);
}
}
+2 -40
View File
@@ -68,28 +68,8 @@ class ActionService {
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 {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive);
}
Future<void> unArchive(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked);
await _assetApiRepository.updateVisibility(remoteIds, .locked);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
// Ask user if they want to delete local copies
@@ -99,20 +79,10 @@ class ActionService {
}
Future<void> removeFromLockFolder(List<String> remoteIds) async {
await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline);
await _assetApiRepository.updateVisibility(remoteIds, .timeline);
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
}
Future<void> trash(List<String> remoteIds) async {
await _assetApiRepository.delete(remoteIds, false);
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 {
final count = await _assetApiRepository.emptyTrash();
await _remoteAssetRepository.emptyTrash(userId);
@@ -224,14 +194,6 @@ class ActionService {
);
}
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) {
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
}
return result.removed.length;
}
Future<bool> updateDescription(String assetId, String description) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateDescription(assetId, description);
+62 -103
View File
@@ -1,36 +1,33 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/archive.action.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/actions/cast.action.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/presentation/actions/lock.action.dart';
import 'package:immich_mobile/presentation/actions/open_in_browser.action.dart';
import 'package:immich_mobile/presentation/actions/remove_from_album.action.dart';
import 'package:immich_mobile/presentation/actions/restore.action.dart';
import 'package:immich_mobile/presentation/actions/set_album_cover.action.dart';
import 'package:immich_mobile/presentation/actions/set_profile_picture.action.dart';
import 'package:immich_mobile/presentation/actions/similar_photos.action.dart';
import 'package:immich_mobile/presentation/actions/slideshow.action.dart';
import 'package:immich_mobile/presentation/actions/stack.action.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/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_permanent_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/like_activity_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/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_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_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_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_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/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/routing/router.dart';
@@ -85,9 +82,7 @@ enum ActionButtonType {
removeFromLockFolder,
removeFromAlbum,
restoreTrash,
trash,
deleteLocal,
deletePermanent,
delete,
advancedInfo;
@@ -112,25 +107,12 @@ enum ActionButtonType {
!context.isInLockedView && //
context.asset.hasRemote && //
!context.asset.hasLocal,
ActionButtonType.trash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled && //
context.timelineOrigin != TimelineOrigin.trash,
ActionButtonType.restoreTrash =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.timelineOrigin == TimelineOrigin.trash,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.delete => true, //
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
@@ -141,7 +123,7 @@ enum ActionButtonType {
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal,
context.asset.isMerged,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@@ -186,84 +168,63 @@ enum ActionButtonType {
}
Widget buildButton(
ActionButtonContext context, [
BuildContext? buildContext,
ActionButtonContext context,
BuildContext buildContext,
WidgetRef ref, [
bool iconOnly = false,
bool menuItem = false,
]) {
final scope = ActionScope.from(buildContext, ref);
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => ActionMenuItemWidget(
action: AssetDebugAction(assets: [context.asset], scope: scope),
),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unarchive => UnArchiveActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.slideshow => ActionMenuItemWidget(action: SlideshowAction(scope: scope)),
ActionButtonType.archive || ActionButtonType.unarchive => ActionMenuItemWidget(
action: ArchiveAction(assets: [context.asset], scope: scope),
),
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.restoreTrash => RestoreActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.restoreTrash => ActionMenuItemWidget(
action: RestoreAction(assets: [context.asset], scope: scope),
),
ActionButtonType.deletePermanent => DeletePermanentActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.delete => ActionMenuItemWidget(
action: DeleteAction(assets: [context.asset], scope: scope),
),
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.moveToLockFolder => ActionMenuItemWidget(
action: LockAction(assets: [context.asset], scope: scope),
),
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.removeFromLockFolder => ActionMenuItemWidget(
action: LockAction(assets: [context.asset], scope: scope),
),
ActionButtonType.deleteLocal => DeleteLocalActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.deleteLocal => ActionMenuItemWidget(
action: CleanupLocalAction(assets: [context.asset], scope: scope),
),
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
albumId: context.currentAlbum!.id,
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.removeFromAlbum => ActionMenuItemWidget(
action: RemoveFromAlbumAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
),
ActionButtonType.setAlbumCover => SetAlbumCoverActionButton(
albumId: context.currentAlbum!.id,
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.setAlbumCover => ActionMenuItemWidget(
action: SetAlbumCoverAction(assets: [context.asset], albumId: context.currentAlbum!.id, scope: scope),
),
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
remoteId: context.asset.remoteId!,
origin: context.timelineOrigin,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.unstack => ActionMenuItemWidget(
action: StackAction(assets: [context.asset], scope: scope),
),
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.openInBrowser => ActionMenuItemWidget(
action: OpenInBrowserAction(remoteId: context.asset.remoteId!, origin: context.timelineOrigin, scope: scope),
),
ActionButtonType.setProfilePicture => SetProfilePictureActionButton(
asset: context.asset,
iconOnly: iconOnly,
menuItem: menuItem,
ActionButtonType.similarPhotos => ActionMenuItemWidget(
action: SimilarPhotosAction(assetId: (context.asset as RemoteAsset).id, scope: scope),
),
ActionButtonType.setProfilePicture => ActionMenuItemWidget(
action: SetProfilePictureAction(asset: context.asset, scope: scope),
),
ActionButtonType.openInfo => BaseActionButton(
label: 'info'.tr(),
@@ -276,14 +237,12 @@ enum ActionButtonType {
iconData: Icons.image_search,
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: buildContext == null
? null
: () async {
await buildContext.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
onPressed: () async {
await buildContext.router.navigate(const TabShellRoute(children: [MainTimelineRoute()]));
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
},
),
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.cast => ActionMenuItemWidget(action: CastAction(scope: scope)),
};
}
@@ -294,8 +253,6 @@ enum ActionButtonType {
// 0: info
ActionButtonType.openInfo => 0,
// 10: move, remove, and delete
ActionButtonType.trash => 10,
ActionButtonType.deletePermanent => 10,
ActionButtonType.removeFromLockFolder => 10,
ActionButtonType.removeFromAlbum => 10,
ActionButtonType.unstack => 10,
@@ -323,14 +280,16 @@ class ActionButtonBuilder {
ActionButtonType.archive,
ActionButtonType.unarchive,
ActionButtonType.restoreTrash,
ActionButtonType.deletePermanent,
};
static List<Widget> build(ActionButtonContext context) {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
static List<Widget> build(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
return _actionTypes
.where((type) => type.shouldShow(context))
.map((type) => type.buildButton(context, buildContext, ref))
.toList();
}
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
final visibleButtons = defaultViewerKebabMenuOrder
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
.toList();
@@ -346,7 +305,7 @@ class ActionButtonBuilder {
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
result.add(const Divider(height: 1));
}
result.add(type.buildButton(context, buildContext, false, true));
result.add(type.buildButton(context, buildContext, ref, false, true));
lastGroup = type.kebabMenuGroup;
}
+24
View File
@@ -0,0 +1,24 @@
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> locked({bool isLocked = true}) => remote().where((asset) => asset.isLocked == isLocked);
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<BaseAsset> backedUp() => where((asset) => asset.isMerged);
}
+3 -7
View File
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
// ignore: depend_on_referenced_packages
import 'package:stack_trace/stack_trace.dart';
void handleError(BuildContext context, Object error, {StackTrace? stack, String? description}) {
void handleError(Object error, {StackTrace? stack, String? description}) {
String? stackTrace;
if (stack != null) {
final trace = Trace.from(stack);
@@ -23,17 +23,13 @@ void handleError(BuildContext context, Object error, {StackTrace? stack, String?
() => 'Error${description != null ? ' ($description)' : ''}: $error${stackTrace != null ? '\n$stackTrace' : ''}',
);
if (!context.mounted) {
return;
}
final String message;
if (serverErrorMessage(error) case String serverMessage) {
message = serverMessage;
} else if (isConnectionError(error)) {
message = context.t.login_form_server_error;
message = StaticTranslations.instance.login_form_server_error;
} else {
message = context.t.scaffold_body_error_occurred;
message = StaticTranslations.instance.scaffold_body_error_occurred;
}
snackbar.error(message);
+13
View File
@@ -1,3 +1,4 @@
import 'package:drift/drift.dart';
import 'package:openapi/api.dart' show Optional;
sealed class Option<T> {
@@ -21,6 +22,11 @@ sealed class Option<T> {
None() => null,
};
Option<U> map<U>(U Function(T value) f) => switch (this) {
Some(:final value) => Some(f(value)),
None() => None<U>(),
};
U fold<U>(U Function(T value) onSome, U Function() onNone) => switch (this) {
Some(:final value) => onSome(value),
None() => onNone(),
@@ -65,3 +71,10 @@ extension OptionToOptional<T> on Option<T> {
Some(:final value) => Optional.present(value),
};
}
extension OptionToDriftValue<T> on Option<T> {
Value<T> toDriftValue() => switch (this) {
Some(:final value) => Value(value),
None() => const Value.absent(),
};
}
+15 -7
View File
@@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(
String message,
SnackbarType type, {
Duration? duration,
}) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
duration ??= const .new(seconds: 4);
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
return messenger.showSnackBar(_build(context, message, type, duration));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
@@ -29,7 +34,7 @@ class SnackbarManager {
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
duration: duration,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
@@ -48,11 +53,14 @@ class SnackbarManager {
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message, {Duration? duration}) =>
show(message, .info, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message, {Duration? duration}) =>
show(message, .success, duration: duration);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message, {Duration? duration}) =>
show(message, .error, duration: duration);
}
const snackbar = SnackbarManager();
+12
View File
@@ -1,10 +1,14 @@
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/partner.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreService extends Mock implements StoreService {}
@@ -20,3 +24,11 @@ class MockPartnerService extends Mock implements PartnerService {}
class MockAssetService extends Mock implements AssetService {}
class MockUserService extends Mock implements UserService {}
class MockRemoteAlbumService extends Mock implements RemoteAlbumService {}
class MockGCastService extends Mock implements GCastService {}
class MockCleanupService extends Mock implements CleanupService {}
class MockServerInfoService extends Mock implements ServerInfoService {}
+13
View File
@@ -0,0 +1,13 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
Logger.root.level = Level.OFF;
EasyLocalization.logger.enableBuildModes = [];
// ignore: banned-usage
debugPrint = (String? message, {int? wrapWidth}) {};
return testMain();
}
@@ -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/log.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_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/store.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_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:mocktail/mocktail.dart';
@@ -50,6 +51,8 @@ class MockUserRepository extends Mock implements UserRepository {}
class MockPartnerRepository extends Mock implements PartnerRepository {}
class MockToastRepository extends Mock implements ToastRepository {}
// API Repos
class MockUserApiRepository extends Mock implements UserApiRepository {}
@@ -1,5 +1,4 @@
@Skip('Flaky test, needs investigation')
@Tags(['widget'])
library;
import 'package:drift/drift.dart';
@@ -5,12 +5,13 @@ import '../../utils.dart';
class LocalAssetFactory {
const LocalAssetFactory();
static LocalAsset create({String? id, String? name}) {
static LocalAsset create({String? id, String? name, String? remoteId}) {
id = TestUtils.uuid(id);
return LocalAsset(
id: id,
name: name ?? 'local_$id.jpg',
remoteId: remoteId,
type: AssetType.image,
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
@@ -0,0 +1,38 @@
import 'package:immich_mobile/domain/models/album/album.model.dart';
import '../../utils.dart';
class RemoteAlbumFactory {
const RemoteAlbumFactory();
static RemoteAlbum create({
String? id,
String? name,
String? ownerId,
String? description,
DateTime? createdAt,
DateTime? updatedAt,
String? thumbnailAssetId,
bool isActivityEnabled = false,
AlbumAssetOrder order = AlbumAssetOrder.desc,
int assetCount = 0,
String? ownerName,
bool isShared = false,
}) {
id = TestUtils.uuid(id);
return RemoteAlbum(
id: id,
name: name ?? 'remote_album_$id',
ownerId: TestUtils.uuid(ownerId),
description: description ?? '',
createdAt: TestUtils.date(createdAt),
updatedAt: TestUtils.date(updatedAt),
thumbnailAssetId: thumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order,
assetCount: assetCount,
ownerName: ownerName ?? 'owner_$id',
isShared: isShared,
);
}
}
@@ -5,7 +5,16 @@ import '../../utils.dart';
class RemoteAssetFactory {
const RemoteAssetFactory();
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
static RemoteAsset create({
String? id,
String? name,
String? ownerId,
bool isFavorite = false,
AssetVisibility visibility = AssetVisibility.timeline,
String? stackId,
DateTime? deletedAt,
String? localId,
}) {
id = TestUtils.uuid(id);
return RemoteAsset(
@@ -17,7 +26,11 @@ class RemoteAssetFactory {
createdAt: TestUtils.yesterday(),
updatedAt: TestUtils.now(),
isFavorite: isFavorite,
visibility: visibility,
stackId: stackId,
isEdited: false,
deletedAt: deletedAt,
localId: localId,
);
}
}
+122 -17
View File
@@ -1,7 +1,12 @@
import 'dart:typed_data';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:mocktail/mocktail.dart' as mock;
import 'package:mocktail/mocktail.dart';
@@ -9,14 +14,16 @@ import '../domain/service.mock.dart';
import '../infrastructure/repository.mock.dart';
import 'factories/local_album_factory.dart';
import 'factories/local_asset_factory.dart';
import 'factories/remote_album_factory.dart';
import 'factories/user_factory.dart';
class RepositoryMocks {
final localAlbum = MockLocalAlbumRepository();
final localAsset = MockDriftLocalAssetRepository();
final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository());
final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository());
final trashedAsset = MockTrashedLocalAssetRepository();
final toast = MockToastRepository();
final nativeApi = MockNativeSyncApi();
final nativeApi = NativeSyncApiStub(MockNativeSyncApi());
RepositoryMocks() {
resetAll();
@@ -24,17 +31,37 @@ class RepositoryMocks {
void resetAll() {
_registerFallbacks();
reset(localAlbum);
reset(localAsset);
localAlbum.reset();
localAsset.reset();
reset(trashedAsset);
reset(nativeApi);
nativeApi.reset();
reset(toast);
_stubLocalAlbumRepository();
_stubLocalAssetRepository();
_stubNativeSyncApi();
}
void _stubLocalAlbumRepository() {
when(localAlbum.getBackupAlbums).thenAnswer((_) async => []);
when(localAlbum.getAssetsToHash).thenAnswer((_) async => []);
}
void _stubLocalAssetRepository() {
when(localAsset.reconcileHashesFromCloudId).thenAnswer((_) async => {});
when(localAsset.updateHashes).thenAnswer((_) async => {});
}
void _stubNativeSyncApi() {
when(nativeApi.hashAssets).thenAnswer((_) async => []);
}
}
class ServiceMocks {
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
final partner = PartnerServiceStub(MockPartnerService());
final user = UserServiceStub(MockUserService());
final asset = AssetServiceStub(MockAssetService());
final album = RemoteAlbumServiceStub(MockRemoteAlbumService());
final cleanup = CleanupServiceStub(MockCleanupService());
ServiceMocks() {
resetAll();
@@ -45,9 +72,13 @@ class ServiceMocks {
partner.reset();
user.reset();
asset.reset();
album.reset();
cleanup.reset();
_stubUserService();
_stubPartnerService();
_stubAssetService();
_stubRemoteAlbumService();
_stubCleanupService();
}
void _stubUserService() {
@@ -68,7 +99,21 @@ class ServiceMocks {
}
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
when(asset.update).thenAnswer((_) async {});
when(asset.stack).thenAnswer((_) async {});
when(asset.unstack).thenAnswer((_) async {});
when(asset.restoreTrash).thenAnswer((_) async {});
when(asset.trash).thenAnswer((_) async {});
when(asset.delete).thenAnswer((_) async {});
}
void _stubRemoteAlbumService() {
when(album.removeAssets).thenAnswer((_) async => 0);
when(album.updateAlbum).thenAnswer((_) async => RemoteAlbumFactory.create());
}
void _stubCleanupService() {
when(cleanup.deleteLocalAssets).thenAnswer((_) async => 0);
}
}
@@ -76,13 +121,33 @@ void _registerFallbacks() {
registerFallbackValue(LocalAlbumFactory.create());
registerFallbackValue(LocalAssetFactory.create());
registerFallbackValue(Uint8List(0));
registerFallbackValue(AssetVisibility.timeline);
registerFallbackValue(const Option<bool>.none());
registerFallbackValue(const Option<AssetVisibility>.none());
}
extension type const Stub<T extends Mock>(T mockedService) {
void reset() => mock.reset(mockedService);
extension type const Stub<T extends Mock>(T mockedClass) {
void reset() => mock.reset(mockedClass);
}
extension type const PartnerStub(MockPartnerService service) implements Stub<MockPartnerService> {
extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) implements Stub<MockLocalAlbumRepository> {
Future<List<LocalAlbum>> Function() get getBackupAlbums =>
() => repo.getBackupAlbums();
Future<List<LocalAsset>> Function() get getAssetsToHash =>
() => repo.getAssetsToHash(any());
}
extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo)
implements Stub<MockDriftLocalAssetRepository> {
Future<void> Function() get reconcileHashesFromCloudId =>
() => repo.reconcileHashesFromCloudId();
Future<void> Function() get updateHashes =>
() => repo.updateHashes(any());
}
extension type const PartnerServiceStub(MockPartnerService service) implements Stub<MockPartnerService> {
Stream<Iterable<User>> Function() get getCandidates =>
() => service.getCandidates(any());
@@ -110,7 +175,7 @@ extension type const PartnerStub(MockPartnerService service) implements Stub<Moc
);
}
extension type const UserStub(MockUserService service) implements Stub<MockUserService> {
extension type const UserServiceStub(MockUserService service) implements Stub<MockUserService> {
UserDto Function() get getMyUser =>
() => service.getMyUser();
@@ -127,7 +192,47 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
() => service.createProfileImage(any(), any());
}
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get update =>
() => service.update(
any(),
isFavorite: any(named: 'isFavorite'),
visibility: any(named: 'visibility'),
);
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());
Future<void> Function() get trash =>
() => service.trash(any());
Future<void> Function() get delete =>
() => service.delete(any());
}
extension type const RemoteAlbumServiceStub(MockRemoteAlbumService service) implements Stub<MockRemoteAlbumService> {
Future<int> Function() get removeAssets =>
() => service.removeAssets(
albumId: any(named: 'albumId'),
assetIds: any(named: 'assetIds'),
);
Future<RemoteAlbum> Function() get updateAlbum =>
() => service.updateAlbum(any(), thumbnailAssetId: any(named: 'thumbnailAssetId'));
}
extension type const CleanupServiceStub(MockCleanupService service) implements Stub<MockCleanupService> {
Future<int> Function() get deleteLocalAssets =>
() => service.deleteLocalAssets(any());
}
extension type const NativeSyncApiStub(MockNativeSyncApi api) implements Stub<MockNativeSyncApi> {
Future<List<HashResult>> Function() get hashAssets =>
() => api.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'));
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
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/archive.action.dart';
import 'package:immich_mobile/utils/option.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({AssetVisibility visibility = AssetVisibility.timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
group('ArchiveAction', () {
testWidgets('archives the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
expect(action.icon, Icons.archive_outlined);
expect(action.label, StaticTranslations.instance.archive);
verify(() => assetService.update([asset.id], visibility: const Some(AssetVisibility.archive))).called(1);
});
testWidgets('unarchive the eligible owned assets', (tester) async {
final asset = owned(visibility: .archive);
final action = await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [asset], scope: scope));
expect(action.icon, Icons.unarchive_outlined);
expect(action.label, StaticTranslations.instance.unarchive);
verify(() => assetService.update([asset.id], visibility: const Some(AssetVisibility.timeline))).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .archive);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(
context,
(scope) => ArchiveAction(assets: [mine, theirs], scope: scope),
);
expect(action.label, StaticTranslations.instance.unarchive);
verify(() => assetService.update([mine.id], visibility: const Some(AssetVisibility.timeline))).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [first, second], scope: scope));
verify(
() => assetService.update([first.id, second.id], visibility: const Some(AssetVisibility.archive)),
).called(1);
});
testWidgets('archives only the owned assets not already archived', (tester) async {
final stale = owned();
final alreadyArchived = owned(visibility: .archive);
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [stale, alreadyArchived], scope: scope));
verify(() => assetService.update([stale.id], visibility: const Some(AssetVisibility.archive))).called(1);
});
testWidgets('reports the archived count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => ArchiveAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.archive_action_prompt(count: 2));
});
testWidgets('reports the unarchive count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
context,
(scope) => ArchiveAction(
assets: [
owned(visibility: .archive),
owned(visibility: .archive),
],
scope: scope,
),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unarchive_action_prompt(count: 2));
});
});
}
@@ -1,12 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_ui/immich_ui.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
@@ -22,20 +21,18 @@ void main() {
group('AssetDebugAction', () {
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
await tester.pumpTestWidget(
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
await tester.pumpActionButton(
context,
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(find.byType(ImmichIconButton), findsOneWidget);
});
testWidgets('hidden for multiple assets', (tester) async {
await tester.pumpTestWidget(
ActionIconButtonWidget(
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
),
overrides: context.overrides,
await tester.pumpActionButton(
context,
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()], scope: scope),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -43,9 +40,9 @@ void main() {
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
await tester.pumpTestWidget(
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
overrides: context.overrides,
await tester.pumpActionButton(
context,
(scope) => AssetDebugAction(assets: [RemoteAssetFactory.create()], scope: scope),
);
expect(find.byType(ImmichIconButton), findsNothing);
@@ -0,0 +1,221 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/delete.action.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/local_asset_factory.dart';
import '../../factories/remote_asset_factory.dart';
import '../../riverpod_mocks.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
late MockCleanupService cleanupService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
cleanupService = context.service.cleanup.service;
});
tearDown(() async {
debugDefaultTargetPlatformOverride = null;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, false);
context.dispose();
});
RemoteAsset owned({AssetVisibility visibility = .timeline, DateTime? deletedAt, String? localId}) =>
RemoteAssetFactory.create(
ownerId: context.currentUser.id,
visibility: visibility,
deletedAt: deletedAt,
localId: localId,
);
List<Override> disableTrash() => [
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier(trashEnabled: false)),
];
Future<void> respondToDialog(WidgetTester tester, {required bool confirm}) async {
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(ConfirmDialog), findsOneWidget);
await tester.tap(find.byType(TextButton).at(confirm ? 1 : 0)); // [cancel, ok]
await tester.pumpAndSettle();
}
group('DeleteAction', () {
group('trash', () {
testWidgets('trashes a remote-only owned asset', (tester) async {
final asset = owned();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
verify(() => assetService.trash([asset.id])).called(1);
verifyNever(() => assetService.delete(any()));
verifyNever(() => cleanupService.deleteLocalAssets(any()));
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [mine, theirs], scope: scope));
await tester.pumpAndSettle();
verify(() => assetService.trash([mine.id])).called(1);
});
testWidgets('trashes a merged asset and removes its device copy', (tester) async {
final asset = owned(localId: 'local');
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
verify(() => assetService.trash([asset.id])).called(1);
});
});
group('permanent', () {
testWidgets('permanently deletes when the trash feature is disabled', (tester) async {
final asset = owned();
await tester.pumpTestAction(
context,
(scope) => DeleteAction(assets: [asset], scope: scope),
overrides: disableTrash(),
);
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
});
testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async {
final asset = owned(localId: 'local');
await tester.pumpTestAction(
context,
(scope) => DeleteAction(assets: [asset], scope: scope),
overrides: disableTrash(),
);
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
});
testWidgets('permanently deletes already trashed assets even with trash enabled', (tester) async {
final asset = owned(deletedAt: DateTime(2024));
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
});
testWidgets('permanently deletes locked folder assets even with trash enabled', (tester) async {
final asset = owned(visibility: .locked, localId: 'local');
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await respondToDialog(tester, confirm: true);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
});
testWidgets('does nothing when the confirmation is cancelled', (tester) async {
final asset = owned(visibility: .locked, localId: 'local');
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await respondToDialog(tester, confirm: false);
verifyNever(() => assetService.delete(any()));
verifyNever(() => cleanupService.deleteLocalAssets(any()));
});
});
group('local only', () {
testWidgets('removes the device copy with no remote call', (tester) async {
final asset = LocalAssetFactory.create();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
verifyNever(() => assetService.trash(any()));
verifyNever(() => assetService.delete(any()));
});
});
group('prompt handling', () {
testWidgets('permanent delete shows a single app dialog', (tester) async {
final asset = owned(localId: 'local');
await tester.pumpTestAction(
context,
(scope) => DeleteAction(assets: [asset], scope: scope),
overrides: disableTrash(),
);
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.delete_dialog_title), findsOneWidget);
await tester.tap(find.byType(TextButton).at(1));
await tester.pumpAndSettle();
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsNothing);
verify(() => assetService.delete([asset.id])).called(1);
verify(() => cleanupService.deleteLocalAssets(['local'])).called(1);
});
testWidgets('local only delete on Android with MANAGE_MEDIA shows the prompt', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await StoreService.I.put(StoreKey.manageLocalMediaAndroid, true);
final asset = LocalAssetFactory.create();
await tester.pumpTestAction(context, (scope) => DeleteAction(assets: [asset], scope: scope));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(StaticTranslations.instance.move_to_device_trash), findsOneWidget);
await tester.tap(find.byType(TextButton).at(1)); // confirm
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets([asset.id])).called(1);
debugDefaultTargetPlatformOverride = null;
});
});
});
group('CleanupLocalAction', () {
testWidgets('deletes only backed up device copies', (tester) async {
final backedUp = LocalAssetFactory.create(remoteId: 'remote');
final localOnly = LocalAssetFactory.create();
await tester.pumpTestAction(context, (scope) => CleanupLocalAction(assets: [backedUp, localOnly], scope: scope));
await tester.pumpAndSettle();
verify(() => cleanupService.deleteLocalAssets([backedUp.id])).called(1);
});
testWidgets('is hidden when no backed up assets are selected', (tester) async {
final action = await tester.pumpActionButton(
context,
(scope) => CleanupLocalAction(assets: [LocalAssetFactory.create()], scope: scope),
);
expect(action.isVisible, isFalse);
});
});
}
@@ -1,82 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockAssetService assetService;
setUp(() async {
context = await PresentationContext.create();
assetService = context.service.asset.service;
});
tearDown(() {
context.dispose();
});
List<Override> overrides() => [
...context.overrides,
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
];
RemoteAsset owned({bool isFavorite = false}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
group('FavoriteAction', () {
testWidgets('favorites the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
expect(action.icon, Icons.favorite_border_rounded);
expect(action.label, StaticTranslations.instance.favorite);
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
verify(() => assetService.update([asset.id], isFavorite: const Some(true))).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
final action = await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [asset], scope: scope));
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
expect(action.icon, Icons.favorite_rounded);
expect(action.label, StaticTranslations.instance.unfavorite);
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
verify(() => assetService.update([asset.id], isFavorite: const Some(false))).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(isFavorite: true);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(
context,
(scope) => FavoriteAction(assets: [mine, theirs], scope: scope),
);
expect(action.label, StaticTranslations.instance.unfavorite);
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
verify(() => assetService.update([mine.id], isFavorite: const Some(false))).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [first, second], scope: scope));
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
verify(() => assetService.update([first.id, second.id], isFavorite: const Some(true))).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
testWidgets('favorites only the owned assets not already favorite', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [stale, alreadyFavorite], scope: scope));
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
verify(() => assetService.update([stale.id], isFavorite: const Some(true))).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpUntilFound(find.byType(SnackBar));
testWidgets('reports the favorite count through the toast repository', (tester) async {
final toast = context.repository.toast;
expect(find.byType(SnackBar), findsOneWidget);
await tester.pumpTestAction(context, (scope) => FavoriteAction(assets: [owned(), owned()], scope: scope));
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,
(scope) => FavoriteAction(assets: [owned(isFavorite: true), owned(isFavorite: true)], scope: scope),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unfavorite_action_prompt(count: 2));
});
});
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
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/lock.action.dart';
import 'package:immich_mobile/utils/option.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({AssetVisibility visibility = .timeline}) =>
RemoteAssetFactory.create(ownerId: context.currentUser.id, visibility: visibility);
group('LockAction', () {
testWidgets('locks the eligible owned assets', (tester) async {
final asset = owned();
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
expect(action.icon, Icons.lock_rounded);
expect(action.label, StaticTranslations.instance.move_to_locked_folder);
verify(() => assetService.update([asset.id], visibility: const Some(.locked))).called(1);
});
testWidgets('unlocks the eligible owned assets', (tester) async {
final asset = owned(visibility: .locked);
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [asset], scope: scope));
expect(action.icon, Icons.lock_open_rounded);
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
verify(() => assetService.update([asset.id], visibility: const Some(.timeline))).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned(visibility: .locked);
final theirs = RemoteAssetFactory.create();
final action = await tester.pumpTestAction(context, (scope) => LockAction(assets: [mine, theirs], scope: scope));
expect(action.label, StaticTranslations.instance.remove_from_locked_folder);
verify(() => assetService.update([mine.id], visibility: const Some(.timeline))).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(context, (scope) => LockAction(assets: [first, second], scope: scope));
verify(() => assetService.update([first.id, second.id], visibility: const Some(.locked))).called(1);
});
testWidgets('locks only the owned assets not already locked', (tester) async {
final stale = owned();
final alreadyLocked = owned(visibility: .locked);
await tester.pumpTestAction(context, (scope) => LockAction(assets: [stale, alreadyLocked], scope: scope));
verify(() => assetService.update([stale.id], visibility: const Some(.locked))).called(1);
});
testWidgets('reports the locked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(context, (scope) => LockAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.move_to_lock_folder_action_prompt(count: 2));
});
testWidgets('reports the unlocked count through the toast repository', (tester) async {
final toast = context.repository.toast;
await tester.pumpTestAction(
context,
(scope) => LockAction(
assets: [
owned(visibility: .locked),
owned(visibility: .locked),
],
scope: scope,
),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.remove_from_lock_folder_action_prompt(count: 2));
});
});
}
@@ -4,17 +4,19 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/presentation/actions/partner.action.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../../domain/service.mock.dart';
import '../../factories/user_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
void main() {
late PresentationContext context;
late MockPartnerService partnerService;
setUp(() async {
context = await PresentationContext.create();
partnerService = context.service.partner.service;
});
tearDown(() {
@@ -22,8 +24,6 @@ void main() {
});
List<Override> overrides({List<User> candidates = const []}) => [
...context.overrides,
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
@@ -31,22 +31,28 @@ void main() {
testWidgets('creates a partner for the selected candidate', (tester) async {
final candidate = UserFactory.create();
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [candidate]));
await tester.pumpTestAction(
context,
(scope) => PartnerAddAction(scope: scope),
overrides: overrides(candidates: [candidate]),
);
await tester.pumpUntilFound(find.text(candidate.name));
await tester.tap(find.text(candidate.name));
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
).called(1);
verify(() => partnerService.create(sharedById: context.currentUser.id, sharedWithId: candidate.id)).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
await tester.pumpTestAction(const PartnerAddAction(), overrides: overrides(candidates: [UserFactory.create()]));
await tester.pumpTestAction(
context,
(scope) => PartnerAddAction(scope: scope),
overrides: overrides(candidates: [UserFactory.create()]),
);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.create);
verifyNever(context.service.partner.create);
});
});
@@ -54,27 +60,27 @@ void main() {
testWidgets('deletes the partner after confirmation', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
context,
(scope) => PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).last); // confirm
await tester.pumpAndSettle();
verify(
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
).called(1);
verify(() => partnerService.delete(sharedById: context.currentUser.id, sharedWithId: partner.id)).called(1);
});
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
final partner = UserFactory.create();
await tester.pumpTestAction(
PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name),
context,
(scope) => PartnerRemoveAction(sharedWithId: partner.id, partnerName: partner.name, scope: scope),
overrides: overrides(),
);
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.delete);
verifyNever(context.service.partner.delete);
});
});
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
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/remove_from_album.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 MockRemoteAlbumService albumService;
setUp(() async {
context = await PresentationContext.create();
albumService = context.service.album.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset remote() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
group('RemoveFromAlbumAction', () {
testWidgets('removes the selected remote assets from the album', (tester) async {
final first = remote();
final second = remote();
final albumId = 'album';
final action = await tester.pumpTestAction(
context,
(scope) => RemoveFromAlbumAction(assets: [first, second], albumId: albumId, scope: scope),
);
expect(action.icon, Icons.remove_circle_outline);
expect(action.label, StaticTranslations.instance.remove_from_album);
verify(() => albumService.removeAssets(albumId: albumId, assetIds: [first.id, second.id])).called(1);
});
testWidgets('reports the removed count through the toast repository', (tester) async {
final toast = context.repository.toast;
final albumId = 'album';
when(context.service.album.removeAssets).thenAnswer((_) async => 2);
await tester.pumpTestAction(
context,
(scope) => RemoveFromAlbumAction(assets: [remote(), remote()], albumId: albumId, scope: scope),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.remove_from_album_action_prompt(count: 2));
});
});
}
@@ -0,0 +1,72 @@
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, (scope) => RestoreAction(assets: [asset], scope: scope));
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, (scope) => RestoreAction(assets: [mine, theirs], scope: scope));
verify(() => assetService.restoreTrash([mine.id])).called(1);
});
testWidgets('restores only the owned assets that are trashed', (tester) async {
final trashed = owned();
final live = owned(trashed: false);
await tester.pumpTestAction(context, (scope) => RestoreAction(assets: [trashed, live], scope: scope));
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, (scope) => RestoreAction(assets: [first, second], scope: scope));
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, (scope) => RestoreAction(assets: [owned(), owned()], scope: scope));
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.assets_restored_count(count: 2));
});
});
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
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/set_album_cover.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 MockRemoteAlbumService albumService;
setUp(() async {
context = await PresentationContext.create();
albumService = context.service.album.service;
});
tearDown(() {
context.dispose();
});
RemoteAsset remote() => RemoteAssetFactory.create(ownerId: context.currentUser.id);
group('SetAlbumCoverAction', () {
testWidgets('sets the selected asset as the album cover', (tester) async {
final asset = remote();
const albumId = 'album';
final action = await tester.pumpTestAction(
context,
(scope) => SetAlbumCoverAction(assets: [asset], albumId: albumId, scope: scope),
);
expect(action.icon, Icons.image_outlined);
expect(action.label, StaticTranslations.instance.set_as_album_cover);
verify(() => albumService.updateAlbum(albumId, thumbnailAssetId: asset.id)).called(1);
});
testWidgets('is hidden unless exactly one remote asset is selected', (tester) async {
const albumId = 'album';
final action = await tester.pumpActionButton(
context,
(scope) => SetAlbumCoverAction(assets: [remote(), remote()], albumId: albumId, scope: scope),
);
expect(action.isVisible, isFalse);
});
});
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
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();
final action = await tester.pumpTestAction(
context,
(scope) => StackAction(assets: [first, second], scope: scope),
);
expect(action.icon, Icons.filter_none_rounded);
expect(action.label, StaticTranslations.instance.stack);
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');
final action = await tester.pumpTestAction(context, (scope) => StackAction(assets: [asset], scope: scope));
expect(action.icon, Icons.layers_clear_outlined);
expect(action.label, StaticTranslations.instance.unstack);
verify(() => assetService.unstack(['stack'])).called(1);
});
testWidgets('prioritizes stack when the owned selection is mixed', (tester) async {
final first = owned();
final second = owned(stackId: 'stack');
final action = await tester.pumpTestAction(
context,
(scope) => StackAction(assets: [first, second], scope: scope),
);
expect(action.label, StaticTranslations.instance.stack);
verify(() => assetService.stack(context.currentUser.id, [first.id, second.id])).called(1);
});
testWidgets('dispatches on owned state, ignoring assets owned by others', (tester) async {
final mine = owned();
final other = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(context, (scope) => StackAction(assets: [mine, other, theirs], scope: scope));
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, (scope) => StackAction(assets: [first, second], scope: scope));
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, (scope) => StackAction(assets: [owned(), owned()], scope: scope));
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,
(scope) => StackAction(
assets: [
owned(stackId: 'stack-1'),
owned(stackId: 'stack-2'),
],
scope: scope,
),
);
final message = verify(() => toast.success(captureAny())).captured.single as String;
expect(message, StaticTranslations.instance.unstacked_assets_count(count: 2));
});
});
}
@@ -7,28 +7,19 @@ import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
import '../presentation_context.dart';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
_FakeAction({required super.scope, bool visible = true, this.error})
: super(icon: Icons.bolt, label: 'fake', isVisible: visible);
final bool visible;
final Object? error;
bool ran = false;
bool? selectionDuringOnAction;
@override
IconData get icon => Icons.bolt;
@override
String label(ActionScope scope) => 'fake';
@override
bool isVisible(ActionScope scope) => visible;
@override
Future<void> onAction(ActionScope scope) async {
Future<void> onAction() async {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
@@ -48,8 +39,7 @@ void main() {
context.dispose();
});
List<Override> seededOverrides() => [
...context.overrides,
List<Override> overrides() => [
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
@@ -61,6 +51,7 @@ void main() {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
@@ -68,16 +59,16 @@ void main() {
return const SizedBox.shrink();
},
),
overrides: seededOverrides(),
overrides: overrides(),
);
return (scope, container);
}
group('TimelineAction', () {
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
final inner = _FakeAction();
final (scope, container) = await pumpScope(tester);
await TimelineAction(action: inner).onAction(scope);
final inner = _FakeAction(scope: scope);
await TimelineAction(action: inner).onAction();
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
@@ -86,19 +77,19 @@ void main() {
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
final error = Exception('boom');
final inner = _FakeAction(error: error);
final (scope, container) = await pumpScope(tester);
final inner = _FakeAction(scope: scope, error: error);
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
await expectLater(TimelineAction(action: inner).onAction(), throwsA(same(error)));
expect(inner.ran, isTrue);
expect(container.read(multiSelectProvider).isEnabled, isTrue);
});
testWidgets('delegates visibility to the wrapped action', (tester) async {
await tester.pumpTestWidget(
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
overrides: context.overrides,
await tester.pumpActionButton(
context,
(scope) => TimelineAction(action: _FakeAction(scope: scope, visible: false)),
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/actions/partner.action.dart';
import '../factories/partner_user_factory.dart';
import '../factories/user_factory.dart';
import '../presentation_context.dart';
import 'presentation_context.dart';
void main() {
late PresentationContext context;
@@ -17,19 +17,16 @@ void main() {
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
final action = const PartnerAddAction();
await tester.pumpTestWidget(const PartnerSharedByList(partners: []), overrides: context.overrides);
await tester.pumpTestWidget(context, const PartnerSharedByList(partners: []));
expect(find.byType(ListView), findsNothing);
expect(find.widgetWithIcon(TextButton, action.icon), findsOneWidget);
expect(find.widgetWithIcon(TextButton, Icons.person_add_rounded), findsOneWidget);
});
testWidgets('renders a tile per partner with name and email', (tester) async {
final partner1 = PartnerFactory.create();
final partner2 = PartnerFactory.create();
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byType(ListTile), findsNWidgets(2));
expect(find.text(partner1.name), findsOneWidget);
expect(find.text(partner1.email), findsOneWidget);
@@ -40,9 +37,8 @@ void main() {
testWidgets('renders a remove action for each partner', (tester) async {
final partner1 = PartnerFactory.create(inTimeline: true);
final partner2 = PartnerFactory.create();
final action = const PartnerRemoveAction(sharedWithId: '', partnerName: '');
await tester.pumpTestWidget(PartnerSharedByList(partners: [partner1, partner2]), overrides: context.overrides);
expect(find.byIcon(action.icon), findsNWidgets(2));
await tester.pumpTestWidget(context, PartnerSharedByList(partners: [partner1, partner2]));
expect(find.byIcon(Icons.person_remove_rounded), findsNWidgets(2));
});
});
@@ -62,13 +58,12 @@ void main() {
}
List<Override> withCandidates(List<User> candidates) => [
...context.overrides,
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
testWidgets('renders an option per candidate fetched from the provider', (tester) async {
final user = UserFactory.create();
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates([user]));
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates([user]));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -78,7 +73,7 @@ void main() {
});
testWidgets('shows no options when the provider returns no candidates', (tester) async {
await tester.pumpTestWidget(dialogWidget(), overrides: withCandidates(const []));
await tester.pumpTestWidget(context, dialogWidget(), overrides: withCandidates(const []));
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -89,7 +84,11 @@ void main() {
testWidgets('pops the selected candidate when an option is tapped', (tester) async {
final user = UserFactory.create();
User? selected;
await tester.pumpTestWidget(dialogWidget(onClosed: (user) => selected = user), overrides: withCandidates([user]));
await tester.pumpTestWidget(
context,
dialogWidget(onClosed: (user) => selected = user),
overrides: withCandidates([user]),
);
await tester.tap(find.byKey(dialogButtonKey));
await tester.pumpAndSettle();
@@ -13,16 +13,28 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
import 'package:immich_mobile/services/gcast.service.dart';
import 'package:immich_mobile/providers/infrastructure/toast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:mocktail/mocktail.dart';
import '../test_utils.dart';
import 'factories/user_factory.dart';
import 'mocks.dart';
import '../../domain/service.mock.dart';
import '../../test_utils.dart';
import '../factories/user_factory.dart';
import '../mocks.dart';
import '../riverpod_mocks.dart';
class PresentationContext {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
PresentationContext._({required UserDto user})
: currentUser = user,
service = ServiceMocks(),
repository = RepositoryMocks() {
setup();
}
@@ -31,9 +43,19 @@ class PresentationContext {
static Drift? _db;
final UserDto currentUser;
final ServiceMocks mocks;
final ServiceMocks service;
final RepositoryMocks repository;
List<Override> get overrides => [currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service))];
List<Override> get overrides => [
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
assetServiceProvider.overrideWithValue(service.asset.service),
partnerServiceProvider.overrideWithValue(service.partner.service),
remoteAlbumServiceProvider.overrideWithValue(service.album.service),
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
gCastServiceProvider.overrideWithValue(MockGCastService()),
serverInfoProvider.overrideWith((ref) => FakeServerInfoNotifier()),
toastRepositoryProvider.overrideWithValue(repository.toast),
];
static Future<PresentationContext> create() async {
TestUtils.init();
@@ -47,18 +69,18 @@ class PresentationContext {
}
void setup() {
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
when(service.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
mocks.resetAll();
service.resetAll();
});
}
}
extension PumpPresentationWidget on WidgetTester {
Future<void> pumpTestWidget(Widget widget, {List<Override> overrides = const []}) async {
Future<void> pumpTestWidget(PresentationContext context, Widget widget, {List<Override> overrides = const []}) async {
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
@@ -69,7 +91,7 @@ extension PumpPresentationWidget on WidgetTester {
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: overrides,
overrides: [...context.overrides, ...overrides],
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
@@ -86,10 +108,34 @@ extension PumpPresentationWidget on WidgetTester {
await pumpAndSettle();
}
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
Future<BaseAction> pumpTestAction(
PresentationContext context,
BaseAction Function(ActionScope scope) build, {
List<Override> overrides = const [],
}) async {
final action = await pumpActionButton(context, build, overrides: overrides);
await tap(find.byType(ImmichIconButton));
await pump();
return action;
}
Future<BaseAction> pumpActionButton(
PresentationContext context,
BaseAction Function(ActionScope scope) build, {
List<Override> overrides = const [],
}) async {
late BaseAction action;
await pumpTestWidget(
context,
Consumer(
builder: (innerContext, ref, _) {
action = build(ActionScope(context: innerContext, ref: ref, authUser: context.currentUser));
return ActionIconButtonWidget(action: action);
},
),
overrides: overrides,
);
return action;
}
Future<void> pumpUntilFound(Finder finder, {int maxFrames = 10}) async {
+9
View File
@@ -0,0 +1,9 @@
import 'package:immich_mobile/providers/server_info.provider.dart';
import '../domain/service.mock.dart';
class FakeServerInfoNotifier extends ServerInfoNotifier {
FakeServerInfoNotifier({bool trashEnabled = true}) : super(MockServerInfoService()) {
state = state.copyWith(serverFeatures: state.serverFeatures.copyWith(trash: trashEnabled));
}
}
@@ -14,14 +14,11 @@ void main() {
setUp(() {
sut = HashService(
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
trashedLocalAssetRepository: mocks.trashedAsset,
);
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
});
tearDown(() {
@@ -32,22 +29,20 @@ void main() {
group('hashAssets', () {
test('skips albums with no assets to hash', () async {
final album = LocalAlbumFactory.create(assetCount: 0);
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(mocks.nativeApi.hashAssets);
});
test('skips empty batches', () async {
final album = LocalAlbumFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => []);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
await sut.hashAssets();
verifyNever(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
verifyNever(mocks.nativeApi.hashAssets);
});
test('processes assets when available', () async {
@@ -55,15 +50,17 @@ void main() {
final asset = LocalAssetFactory.create();
final result = HashResult(assetId: asset.id, hash: 'test-hash');
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).thenAnswer((_) async => [result]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [result]);
await sut.hashAssets();
verify(() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false)).called(1);
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset.id], result.hash);
});
@@ -72,16 +69,16 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, error: 'Failed to hash')]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
@@ -89,25 +86,25 @@ void main() {
final album = LocalAlbumFactory.create();
final asset = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
when(
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
() => mocks.nativeApi.api.hashAssets([asset.id], allowNetworkAccess: false),
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: null)]);
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 0);
});
test('batches by size limit', () async {
const batchSize = 2;
final sut = HashService(
localAlbumRepository: mocks.localAlbum,
localAssetRepository: mocks.localAsset,
nativeSyncApi: mocks.nativeApi,
localAlbumRepository: mocks.localAlbum.repo,
localAssetRepository: mocks.localAsset.repo,
nativeSyncApi: mocks.nativeApi.api,
batchSize: batchSize,
trashedLocalAssetRepository: mocks.trashedAsset,
);
@@ -119,12 +116,9 @@ void main() {
final capturedCalls = <List<String>>[];
when(() => mocks.localAsset.updateHashes(any())).thenAnswer((_) async => {});
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2, asset3]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
capturedCalls.add(List<String>.from(assetIds));
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
@@ -136,7 +130,7 @@ void main() {
expect(capturedCalls[0], [asset1.id, asset2.id], reason: 'First call should batch the first two assets');
expect(capturedCalls[1], [asset3.id], reason: 'Second call should have the remaining asset');
verify(() => mocks.localAsset.updateHashes(any())).called(2);
verify(() => mocks.localAsset.repo.updateHashes(any())).called(2);
});
test('handles mixed success and failure in batch', () async {
@@ -144,9 +138,9 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [album]);
when(() => mocks.localAlbum.repo.getAssetsToHash(album.id)).thenAnswer((_) async => [asset1, asset2]);
when(() => mocks.nativeApi.api.hashAssets([asset1.id, asset2.id], allowNetworkAccess: false)).thenAnswer(
(_) async => [
HashResult(assetId: asset1.id, hash: 'asset1-hash'),
HashResult(assetId: asset2.id, error: 'Failed to hash asset2'),
@@ -156,7 +150,7 @@ void main() {
await sut.hashAssets();
final captured =
verify(() => mocks.localAsset.updateHashes(captureAny())).captured.first as Map<String, String>;
verify(() => mocks.localAsset.repo.updateHashes(captureAny())).captured.first as Map<String, String>;
expect(captured.length, 1);
expect(captured[asset1.id], 'asset1-hash');
});
@@ -167,20 +161,18 @@ void main() {
final asset1 = LocalAssetFactory.create();
final asset2 = LocalAssetFactory.create();
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(() => mocks.nativeApi.hashAssets(any(), allowNetworkAccess: any(named: 'allowNetworkAccess'))).thenAnswer((
invocation,
) async {
when(mocks.localAlbum.getBackupAlbums).thenAnswer((_) async => [selectedAlbum, nonSelectedAlbum]);
when(() => mocks.localAlbum.repo.getAssetsToHash(selectedAlbum.id)).thenAnswer((_) async => [asset1]);
when(() => mocks.localAlbum.repo.getAssetsToHash(nonSelectedAlbum.id)).thenAnswer((_) async => [asset2]);
when(mocks.nativeApi.hashAssets).thenAnswer((invocation) async {
final assetIds = invocation.positionalArguments[0] as List<String>;
return assetIds.map((id) => HashResult(assetId: id, hash: '$id-hash')).toList();
});
await sut.hashAssets();
verify(() => mocks.nativeApi.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset1.id], allowNetworkAccess: true)).called(1);
verify(() => mocks.nativeApi.api.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
});
});
});
@@ -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<BaseAsset> 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]);
});
});
});
}
@@ -1,11 +1,14 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.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/utils/action_button.utils.dart';
import '../unit/presentation/presentation_context.dart';
LocalAsset createLocalAsset({
String? remoteId,
String name = 'test.jpg',
@@ -77,6 +80,23 @@ RemoteAlbum createRemoteAlbum({
);
}
ActionButtonContext _buttonContext({
required BaseAsset asset,
RemoteAlbum? currentAlbum,
bool isArchived = false,
bool isStacked = false,
}) => ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: isArchived,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: currentAlbum,
advancedTroubleshooting: false,
isStacked: isStacked,
source: ActionSource.timeline,
);
void main() {
group('ActionButtonContext', () {
test('should create context with all required parameters', () {
@@ -427,60 +447,6 @@ void main() {
});
});
group('trash button', () {
test('should show when owner, not locked, has remote, and trash enabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
});
test('should not show when trash disabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
test('should not show when asset is already trashed', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
});
});
group('restoreTrash button', () {
test('should show when owner, not locked, has remote, and is in trash timeline', () {
final remoteAsset = createRemoteAsset();
@@ -519,60 +485,6 @@ void main() {
});
});
group('deletePermanent button', () {
test('should show when owner, not locked, has remote, and trash disabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: false,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
test('should not show when trash enabled', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
});
test('should show when asset is trashed even with trash enabled', () {
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.viewer,
timelineOrigin: TimelineOrigin.trash,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
});
});
group('delete button', () {
test('should show when owner, not locked, and has remote', () {
final remoteAsset = createRemoteAsset();
@@ -612,7 +524,7 @@ void main() {
});
group('deleteLocal button', () {
test('should show when not locked and asset is local only', () {
test('should not show when asset is local only', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
@@ -626,23 +538,6 @@ void main() {
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
});
test('should not show when asset is not local only', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
});
@@ -1054,188 +949,101 @@ void main() {
});
group('ActionButtonType.buildButton', () {
late PresentationContext presentation;
late BaseAsset asset;
late ActionButtonContext context;
setUp(() {
setUp(() async {
presentation = await PresentationContext.create();
asset = createLocalAsset(remoteId: 'remote-id');
context = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
});
test('should build correct widget for each button type', () {
for (final buttonType in ActionButtonType.values) {
var buttonContext = context;
tearDown(() {
presentation.dispose();
});
if (buttonType == ActionButtonType.removeFromAlbum) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.similarPhotos) {
final contextWithAlbum = ActionButtonContext(
asset: createRemoteAsset(),
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.setAlbumCover) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else if (buttonType == ActionButtonType.unstack) {
final album = createRemoteAlbum();
final contextWithAlbum = ActionButtonContext(
asset: asset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
} else {
final widget = buttonType.buildButton(buttonContext);
expect(widget, isA<Widget>());
}
}
testWidgets('builds a widget for every button type', (tester) async {
final built = <Widget>[];
await tester.pumpTestWidget(
presentation,
Consumer(
builder: (buildContext, ref, _) {
for (final buttonType in ActionButtonType.values) {
final buttonContext = switch (buttonType) {
ActionButtonType.similarPhotos => _buttonContext(asset: createRemoteAsset()),
ActionButtonType.removeFromAlbum ||
ActionButtonType.setAlbumCover => _buttonContext(asset: asset, currentAlbum: createRemoteAlbum()),
ActionButtonType.unstack => _buttonContext(
asset: asset,
currentAlbum: createRemoteAlbum(),
isStacked: true,
),
_ => _buttonContext(asset: asset),
};
built.add(buttonType.buildButton(buttonContext, buildContext, ref));
}
return const SizedBox.shrink();
},
),
);
expect(built, hasLength(ActionButtonType.values.length));
expect(built, everyElement(isA<Widget>()));
});
});
group('ActionButtonBuilder', () {
test('should return buttons that should show', () {
final remoteAsset = createRemoteAsset();
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
late PresentationContext presentation;
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
expect(widgets.length, greaterThan(0));
setUp(() async {
presentation = await PresentationContext.create();
});
test('should include album-specific buttons when album is present', () {
final remoteAsset = createRemoteAsset();
final album = createRemoteAlbum(isActivityEnabled: true, isShared: true);
final context = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
tearDown(() {
presentation.dispose();
});
Future<List<Widget>> buildButtons(WidgetTester tester, ActionButtonContext context) async {
late List<Widget> widgets;
await tester.pumpTestWidget(
presentation,
Consumer(
builder: (buildContext, ref, _) {
widgets = ActionButtonBuilder.build(context, buildContext, ref);
return const SizedBox.shrink();
},
),
);
return widgets;
}
final widgets = ActionButtonBuilder.build(context);
testWidgets('returns buttons that should show', (tester) async {
final widgets = await buildButtons(tester, _buttonContext(asset: createRemoteAsset()));
expect(widgets, isNotEmpty);
});
test('should only include local buttons for local assets', () {
final localAsset = createLocalAsset();
final context = ActionButtonContext(
asset: localAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
testWidgets('includes album-specific buttons when album is present', (tester) async {
final widgets = await buildButtons(
tester,
_buttonContext(
asset: createRemoteAsset(),
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true),
),
);
final widgets = ActionButtonBuilder.build(context);
expect(widgets, isNotEmpty);
});
test('should respect archived state', () {
testWidgets('includes local buttons for local assets', (tester) async {
final widgets = await buildButtons(tester, _buttonContext(asset: createLocalAsset()));
expect(widgets, isNotEmpty);
});
testWidgets('respects archived state', (tester) async {
final remoteAsset = createRemoteAsset();
final archived = await buildButtons(tester, _buttonContext(asset: remoteAsset, isArchived: true));
final nonArchived = await buildButtons(tester, _buttonContext(asset: remoteAsset, isArchived: false));
final archivedContext = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: true,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final archivedWidgets = ActionButtonBuilder.build(archivedContext);
final nonArchivedContext = ActionButtonContext(
asset: remoteAsset,
isOwner: true,
isArchived: false,
isTrashEnabled: true,
isInLockedView: false,
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
);
final nonArchivedWidgets = ActionButtonBuilder.build(nonArchivedContext);
expect(archivedWidgets, isNotEmpty);
expect(nonArchivedWidgets, isNotEmpty);
expect(archived, isNotEmpty);
expect(nonArchived, isNotEmpty);
});
});
}
+33 -9
View File
@@ -1,27 +1,51 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/locales.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
extension PumpConsumerWidget on WidgetTester {
/// Wraps the provided [widget] with Material app such that it becomes:
/// Wraps the provided [widget] with a localized Material app such that it
/// becomes:
///
/// EasyLocalization
/// |-ProviderScope
/// |-MaterialApp (localization delegates wired up)
/// |-Material
/// |-[widget]
///
/// ProviderScope
/// |-MaterialApp
/// |-Material
/// |-[widget]
Future<void> pumpConsumerWidget(
Widget widget, {
Duration? duration,
EnginePhase phase = EnginePhase.sendSemanticsUpdate,
List<Override> overrides = const [],
}) async {
return pumpWidget(
ProviderScope(
overrides: overrides,
child: MaterialApp(debugShowCheckedModeBanner: false, home: Material(child: widget)),
await pumpWidget(
EasyLocalization(
supportedLocales: locales.values.toList(),
path: translationsPath,
startLocale: locales.values.first,
fallbackLocale: locales.values.first,
saveLocale: false,
useFallbackTranslations: true,
assetLoader: const CodegenLoader(),
child: ProviderScope(
overrides: overrides,
child: Builder(
builder: (context) => MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Material(child: widget),
),
),
),
),
duration: duration,
phase: phase,
);
await pumpAndSettle();
}
}
@@ -53,10 +53,10 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
RegionList: {
Area: {
// (X,Y) // center of the rectangle
X: number;
Y: number;
W: number;
H: number;
X: number | string;
Y: number | string;
W: number | string;
H: number | string;
Unit: string;
};
Rotation?: number;

Some files were not shown because too many files have changed in this diff Show More