mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 13:07:04 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ae3e93ae | |||
| 64b83031e2 | |||
| 28336c8a7b | |||
| 2fbbd7da58 | |||
| e6893adf2f | |||
| 68b04e8e8f | |||
| 58b1c5e8fb |
+1
-1
@@ -432,7 +432,7 @@
|
||||
"transcoding_realtime_enabled": "Enable real-time transcoding",
|
||||
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
|
||||
"transcoding_realtime_resolutions": "Resolutions",
|
||||
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. A variant is only offered when its resolution is no larger than the source. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
|
||||
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
|
||||
"transcoding_realtime_video_codecs": "Video codecs",
|
||||
"transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.",
|
||||
"transcoding_reference_frames": "Reference frames",
|
||||
|
||||
@@ -107,3 +107,67 @@ Java_app_alextran_immich_NativeImage_rotate(
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
||||
uint32_t *dstRow = dst + (size_t) y * w;
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint32_t px = srcRow[x];
|
||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w = (int) info.width;
|
||||
int h = (int) info.height;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {w, h, w * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,14 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
|
||||
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
|
||||
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
|
||||
* caller can fall back to a Skia copy.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
}
|
||||
|
||||
@@ -46,22 +46,47 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
||||
}
|
||||
|
||||
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
val size = width * height * 4
|
||||
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
|
||||
// colors when copied verbatim. Convert those straight into the output buffer in native code -
|
||||
// one pass, no intermediate ARGB_8888 bitmap.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.convert1010102(this, info)
|
||||
if (pointer != 0L) {
|
||||
recycle()
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
}
|
||||
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
|
||||
}
|
||||
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
|
||||
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
|
||||
val converted = copy(Bitmap.Config.ARGB_8888, false)
|
||||
recycle()
|
||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
||||
} else {
|
||||
this
|
||||
}
|
||||
val size = bitmap.width * bitmap.height * 4
|
||||
val pointer = NativeBuffer.allocate(size)
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, size)
|
||||
copyPixelsToBuffer(buffer)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to width.toLong(),
|
||||
"height" to height.toLong(),
|
||||
"rowBytes" to (width * 4).toLong()
|
||||
"width" to bitmap.width.toLong(),
|
||||
"height" to bitmap.height.toLong(),
|
||||
"rowBytes" to (bitmap.width * 4).toLong()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
NativeBuffer.free(pointer)
|
||||
throw e
|
||||
} finally {
|
||||
recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
@@ -22,6 +24,7 @@ import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
@@ -36,12 +39,16 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
|
||||
companion object {
|
||||
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
||||
|
||||
// Shared, process-lifetime pool: RemoteImagesImpl is re-created per FlutterEngine, so a
|
||||
// per-instance pool would leak threads across engine restarts.
|
||||
private val decodeExecutor = Executors.newFixedThreadPool(2)
|
||||
}
|
||||
|
||||
override fun requestImage(
|
||||
url: String,
|
||||
requestId: Long,
|
||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
preferEncoded: Boolean,
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
@@ -51,12 +58,51 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
url,
|
||||
signal,
|
||||
onSuccess = { buffer ->
|
||||
requestMap.remove(requestId)
|
||||
if (signal.isCanceled) {
|
||||
NativeBuffer.free(buffer.pointer)
|
||||
requestMap.remove(requestId)
|
||||
buffer.free()
|
||||
return@fetch callback(CANCELLED)
|
||||
}
|
||||
|
||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||
// decode failure (including OOM on huge originals), hand Flutter the encoded bytes as
|
||||
// before.
|
||||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
decodeExecutor.execute {
|
||||
val res = if (signal.isCanceled) null else try {
|
||||
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
||||
source.decodeBitmap().toNativeBuffer()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
requestMap.remove(requestId)
|
||||
when {
|
||||
// Deliver even if the request was cancelled meanwhile: re-checking here would orphan
|
||||
// res's malloc, and Dart frees the buffer itself when it sees the cancel.
|
||||
res != null -> {
|
||||
buffer.free()
|
||||
callback(Result.success(res))
|
||||
}
|
||||
signal.isCanceled -> {
|
||||
buffer.free()
|
||||
callback(CANCELLED)
|
||||
}
|
||||
else -> callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
"pointer" to buffer.pointer,
|
||||
"length" to buffer.offset.toLong()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return@fetch
|
||||
}
|
||||
|
||||
requestMap.remove(requestId)
|
||||
callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
|
||||
@@ -15,6 +15,8 @@ enum ActionSource { timeline, viewer }
|
||||
|
||||
enum ShareAssetType { original, preview }
|
||||
|
||||
enum ButtonPosition { bottomBar, kebabMenu }
|
||||
|
||||
enum CleanupStep { selectDate, scan, delete }
|
||||
|
||||
enum AssetKeepType { none, photosOnly, videosOnly }
|
||||
|
||||
@@ -12,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
|
||||
@@ -17,9 +17,14 @@ class AssetDebugAction extends AssetAction<BaseAsset> {
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.troubleshoot;
|
||||
|
||||
static bool canShow({required int assetCount, required bool advancedTroubleshooting}) =>
|
||||
assetCount == 1 && advancedTroubleshooting;
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) =>
|
||||
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||
bool isVisible(ActionScope scope) => canShow(
|
||||
assetCount: assets.length,
|
||||
advancedTroubleshooting: scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async =>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.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/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class EditImageAction extends AssetAction<BaseAsset> {
|
||||
const EditImageAction({required super.assets});
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.tune;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.edit;
|
||||
|
||||
static bool canShow({required BaseAsset asset, required bool isInLockedView, required SemVer serverVersion}) =>
|
||||
!isInLockedView &&
|
||||
asset.isEditable &&
|
||||
// edit sync was added in 2.6.0
|
||||
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0);
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) =>
|
||||
assets.length == 1 &&
|
||||
canShow(
|
||||
asset: assets.first,
|
||||
isInLockedView: scope.ref.watch(inLockedViewProvider),
|
||||
serverVersion: scope.ref.watch(serverInfoProvider.select((state) => state.serverVersion)),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final ActionScope(:context, :ref) = scope;
|
||||
final asset = assets.firstOrNull;
|
||||
if (asset == null || asset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageProvider = getFullImageProvider(asset, edited: false);
|
||||
final image = Image(image: imageProvider);
|
||||
final (edits, exifInfo) = await (
|
||||
ref.read(remoteAssetRepositoryProvider).getAssetEdits(asset.remoteId!),
|
||||
ref.read(remoteAssetRepositoryProvider).getExif(asset.remoteId!),
|
||||
).wait;
|
||||
|
||||
if (exifInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
|
||||
await context.pushRoute(
|
||||
DriftEditImageRoute(
|
||||
image: image,
|
||||
applyEdits: (edits) => ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class OpenActivityAction extends AssetAction<BaseAsset> {
|
||||
const OpenActivityAction({required super.assets});
|
||||
|
||||
@override
|
||||
IconData get icon => Icons.chat_outlined;
|
||||
|
||||
@override
|
||||
String label(ActionScope scope) => scope.context.t.activity;
|
||||
|
||||
static bool canShow({required bool isInLockedView, required RemoteAlbum? album}) =>
|
||||
!isInLockedView && album != null && album.isActivityEnabled && album.isShared;
|
||||
|
||||
@override
|
||||
bool isVisible(ActionScope scope) => canShow(
|
||||
isInLockedView: scope.ref.watch(inLockedViewProvider),
|
||||
album: scope.ref.watch(currentRemoteAlbumProvider),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> onAction(ActionScope scope) async {
|
||||
final album = scope.ref.read(currentRemoteAlbumProvider);
|
||||
final asset = assets.firstOrNull;
|
||||
if (album == null || asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(
|
||||
scope.context.pushRoute(
|
||||
DriftActivitiesRoute(album: album, assetId: asset is RemoteAsset ? asset.id : null, assetName: asset.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.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/asset.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
const EditImageActionButton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
|
||||
Future<void> editImage(List<AssetEdit> edits) async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
|
||||
}
|
||||
|
||||
Future<void> onPress() async {
|
||||
if (currentAsset == null || currentAsset.remoteId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageProvider = getFullImageProvider(currentAsset, edited: false);
|
||||
|
||||
final image = Image(image: imageProvider);
|
||||
final (edits, exifInfo) = await (
|
||||
ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!),
|
||||
ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!),
|
||||
).wait;
|
||||
|
||||
if (exifInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(editorStateProvider.notifier).init(edits, exifInfo);
|
||||
await context.pushRoute(DriftEditImageRoute(image: image, applyEdits: editImage));
|
||||
}
|
||||
|
||||
return BaseActionButton(
|
||||
iconData: Icons.tune,
|
||||
label: "edit".t(context: context),
|
||||
onPressed: onPress,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,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/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.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';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||
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/utils/action_button.utils.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||
|
||||
class ViewerBottomBar extends ConsumerWidget {
|
||||
@@ -37,35 +31,31 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
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);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
final album = ref.watch(currentRemoteAlbumProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
final isTrashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final serverVersion = ref.watch(serverInfoProvider.select((state) => state.serverVersion));
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
final buttonContext = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: isOwner,
|
||||
isArchived: isArchived,
|
||||
isTrashEnabled: isTrashEnabled,
|
||||
isStacked: asset is RemoteAsset && asset.stackId != null,
|
||||
isInLockedView: isInLockedView,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: advancedTroubleshooting,
|
||||
source: ActionSource.viewer,
|
||||
timelineOrigin: timelineOrigin,
|
||||
originalTheme: originalTheme,
|
||||
serverVersion: serverVersion,
|
||||
);
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
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),
|
||||
],
|
||||
],
|
||||
];
|
||||
final actions = ActionButtonBuilder.buildViewerBottomBar(buttonContext, context);
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.short4,
|
||||
|
||||
@@ -31,6 +31,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final serverVersion = ref.watch(serverInfoProvider.select((state) => state.serverVersion));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||
@@ -48,6 +49,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
source: ActionSource.viewer,
|
||||
isCasting: isCasting,
|
||||
timelineOrigin: timelineOrigin,
|
||||
serverVersion: serverVersion,
|
||||
);
|
||||
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/timezone.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
@@ -47,20 +46,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
final actions = <Widget>[
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_outlined),
|
||||
onPressed: () {
|
||||
context.router.push(
|
||||
DriftActivitiesRoute(
|
||||
album: album,
|
||||
assetId: asset is RemoteAsset ? asset.id : null,
|
||||
assetName: asset.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
|
||||
|
||||
ViewerKebabMenu(originalTheme: originalTheme),
|
||||
|
||||
@@ -9,6 +9,9 @@ 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.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_image.action.dart';
|
||||
import 'package:immich_mobile/presentation/actions/open_activity.action.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||
@@ -16,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_
|
||||
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/add_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';
|
||||
@@ -47,6 +51,8 @@ class ActionButtonContext {
|
||||
final bool isCasting;
|
||||
final TimelineOrigin timelineOrigin;
|
||||
final int selectedCount;
|
||||
final ThemeData? originalTheme;
|
||||
final SemVer serverVersion;
|
||||
|
||||
const ActionButtonContext({
|
||||
required this.asset,
|
||||
@@ -61,13 +67,17 @@ class ActionButtonContext {
|
||||
this.isCasting = false,
|
||||
this.timelineOrigin = TimelineOrigin.main,
|
||||
this.selectedCount = 1,
|
||||
this.originalTheme,
|
||||
this.serverVersion = const SemVer(major: 0, minor: 0, patch: 0),
|
||||
});
|
||||
}
|
||||
|
||||
enum ActionButtonType {
|
||||
openInfo,
|
||||
openActivity,
|
||||
likeActivity,
|
||||
share,
|
||||
editImage,
|
||||
shareLink,
|
||||
cast,
|
||||
setAlbumCover,
|
||||
@@ -89,11 +99,22 @@ enum ActionButtonType {
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
delete,
|
||||
addTo,
|
||||
advancedInfo;
|
||||
|
||||
bool _isInActivityAlbum(ActionButtonContext context) {
|
||||
return !context.isInLockedView &&
|
||||
context.currentAlbum != null &&
|
||||
context.currentAlbum!.isActivityEnabled &&
|
||||
context.currentAlbum!.isShared;
|
||||
}
|
||||
|
||||
bool shouldShow(ActionButtonContext context) {
|
||||
return switch (this) {
|
||||
ActionButtonType.advancedInfo => context.advancedTroubleshooting,
|
||||
ActionButtonType.advancedInfo => AssetDebugAction.canShow(
|
||||
assetCount: context.selectedCount,
|
||||
advancedTroubleshooting: context.advancedTroubleshooting,
|
||||
),
|
||||
ActionButtonType.share => true,
|
||||
ActionButtonType.shareLink =>
|
||||
!context.isInLockedView && //
|
||||
@@ -159,11 +180,7 @@ enum ActionButtonType {
|
||||
!context.isInLockedView && //
|
||||
context.isStacked,
|
||||
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
|
||||
ActionButtonType.likeActivity =>
|
||||
!context.isInLockedView &&
|
||||
context.currentAlbum != null &&
|
||||
context.currentAlbum!.isActivityEnabled &&
|
||||
context.currentAlbum!.isShared,
|
||||
ActionButtonType.likeActivity => _isInActivityAlbum(context),
|
||||
ActionButtonType.similarPhotos =>
|
||||
!context.isInLockedView && //
|
||||
context.asset is RemoteAsset,
|
||||
@@ -181,10 +198,29 @@ enum ActionButtonType {
|
||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||
context.isOwner,
|
||||
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||
ActionButtonType.editImage => EditImageAction.canShow(
|
||||
asset: context.asset,
|
||||
isInLockedView: context.isInLockedView,
|
||||
serverVersion: context.serverVersion,
|
||||
),
|
||||
ActionButtonType.addTo =>
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote,
|
||||
ActionButtonType.openActivity => OpenActivityAction.canShow(
|
||||
isInLockedView: context.isInLockedView,
|
||||
album: context.currentAlbum,
|
||||
),
|
||||
ActionButtonType.slideshow => true,
|
||||
};
|
||||
}
|
||||
|
||||
bool showsAt(ButtonPosition position, ActionButtonContext context) => switch (this) {
|
||||
ActionButtonType.openActivity || ActionButtonType.likeActivity =>
|
||||
position != ButtonPosition.bottomBar || context.timelineOrigin != TimelineOrigin.trash,
|
||||
ActionButtonType.editImage => position != ButtonPosition.bottomBar || context.currentAlbum?.isShared != true,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
Widget buildButton(
|
||||
ActionButtonContext context, [
|
||||
BuildContext? buildContext,
|
||||
@@ -217,8 +253,14 @@ enum ActionButtonType {
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
useShortLabel: false,
|
||||
),
|
||||
ActionButtonType.delete => DeleteActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
showConfirmation: true,
|
||||
),
|
||||
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
@@ -284,6 +326,15 @@ enum ActionButtonType {
|
||||
},
|
||||
),
|
||||
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.editImage when menuItem => ActionMenuItemWidget(
|
||||
action: EditImageAction(assets: [context.asset]),
|
||||
),
|
||||
ActionButtonType.editImage => ActionColumnButtonWidget(action: EditImageAction(assets: [context.asset])),
|
||||
ActionButtonType.addTo => AddActionButton(originalTheme: context.originalTheme),
|
||||
ActionButtonType.openActivity when menuItem => ActionMenuItemWidget(
|
||||
action: OpenActivityAction(assets: [context.asset]),
|
||||
),
|
||||
ActionButtonType.openActivity => ActionColumnButtonWidget(action: OpenActivityAction(assets: [context.asset])),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -315,34 +366,62 @@ enum ActionButtonType {
|
||||
class ActionButtonBuilder {
|
||||
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
||||
static const List<ActionButtonType> defaultViewerKebabMenuOrder = _actionTypes;
|
||||
static const Set<ActionButtonType> defaultViewerBottomBarButtons = {
|
||||
static const List<ActionButtonType> _defaultViewerBottomBarOrder = [
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.moveToLockFolder,
|
||||
ActionButtonType.upload,
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.editImage,
|
||||
ActionButtonType.addTo,
|
||||
ActionButtonType.openActivity,
|
||||
ActionButtonType.likeActivity,
|
||||
ActionButtonType.removeFromLockFolder,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
};
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.deleteLocal,
|
||||
];
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||
final visibleButtons = defaultViewerKebabMenuOrder
|
||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||
static List<ActionButtonType> getViewerBottomBarTypes(ActionButtonContext context) {
|
||||
return _defaultViewerBottomBarOrder
|
||||
.where((type) => type.shouldShow(context) && type.showsAt(ButtonPosition.bottomBar, context))
|
||||
.take(4)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (visibleButtons.isEmpty) {
|
||||
static List<ActionButtonType> getViewerKebabMenuTypes(ActionButtonContext context) {
|
||||
final inBottomBar = getViewerBottomBarTypes(context).toSet();
|
||||
return defaultViewerKebabMenuOrder
|
||||
.where(
|
||||
(type) =>
|
||||
!inBottomBar.contains(type) &&
|
||||
type.shouldShow(context) &&
|
||||
type.showsAt(ButtonPosition.kebabMenu, context),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||
return getViewerKebabMenuTypes(context).toKebabMenuWidgets(context, buildContext);
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerBottomBar(ActionButtonContext context, BuildContext buildContext) {
|
||||
return getViewerBottomBarTypes(context).toBottomBarWidgets(context, buildContext);
|
||||
}
|
||||
}
|
||||
|
||||
extension ActionButtonTypeListExtension on List<ActionButtonType> {
|
||||
List<Widget> toKebabMenuWidgets(ActionButtonContext context, BuildContext buildContext) {
|
||||
if (isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<Widget> result = [];
|
||||
int? lastGroup;
|
||||
|
||||
for (final type in visibleButtons) {
|
||||
for (final type in this) {
|
||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||
result.add(const Divider(height: 1));
|
||||
}
|
||||
@@ -352,4 +431,8 @@ class ActionButtonBuilder {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Widget> toBottomBarWidgets(ActionButtonContext context, BuildContext buildContext) {
|
||||
return map((type) => type.buildButton(context, buildContext, false, false)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
|
||||
import '../../utils.dart';
|
||||
|
||||
class RemoteAlbumFactory {
|
||||
const RemoteAlbumFactory();
|
||||
|
||||
static RemoteAlbum create({String? id, String? ownerId, bool isActivityEnabled = false, bool isShared = false}) {
|
||||
id = TestUtils.uuid(id);
|
||||
|
||||
return RemoteAlbum(
|
||||
id: id,
|
||||
name: 'album_$id',
|
||||
ownerId: TestUtils.uuid(ownerId),
|
||||
description: '',
|
||||
createdAt: TestUtils.yesterday(),
|
||||
updatedAt: TestUtils.now(),
|
||||
isActivityEnabled: isActivityEnabled,
|
||||
order: AlbumAssetOrder.desc,
|
||||
assetCount: 1,
|
||||
ownerName: 'owner',
|
||||
isShared: isShared,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/edit_image.action.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/services/server_info.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../factories/local_asset_factory.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
class _MockServerInfoService extends Mock implements ServerInfoService {}
|
||||
|
||||
class _StubServerInfoNotifier extends ServerInfoNotifier {
|
||||
_StubServerInfoNotifier(ServerVersion version) : super(_MockServerInfoService()) {
|
||||
state = state.copyWith(serverVersion: version);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
|
||||
const editCapableVersion = ServerVersion(major: 2, minor: 6, patch: 0);
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
Future<void> pumpAction(
|
||||
WidgetTester tester,
|
||||
EditImageAction action, {
|
||||
ServerVersion serverVersion = editCapableVersion,
|
||||
bool isInLockedView = false,
|
||||
}) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: action),
|
||||
overrides: [
|
||||
serverInfoProvider.overrideWith((ref) => _StubServerInfoNotifier(serverVersion)),
|
||||
inLockedViewProvider.overrideWith((ref) => isInLockedView),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
group('EditImageAction', () {
|
||||
testWidgets('visible for an editable remote asset on a capable server', (tester) async {
|
||||
await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create()]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden for a local-only asset', (tester) async {
|
||||
await pumpAction(tester, EditImageAction(assets: [LocalAssetFactory.create()]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden when the server is older than 2.6.0', (tester) async {
|
||||
await pumpAction(
|
||||
tester,
|
||||
EditImageAction(assets: [RemoteAssetFactory.create()]),
|
||||
serverVersion: const ServerVersion(major: 2, minor: 5, patch: 9),
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden in the locked view', (tester) async {
|
||||
await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create()]), isInLockedView: true);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden for multiple assets', (tester) async {
|
||||
await pumpAction(tester, EditImageAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
group('EditImageAction.canShow', () {
|
||||
test('requires an editable asset, no locked view, and server >= 2.6.0', () {
|
||||
final asset = RemoteAssetFactory.create();
|
||||
|
||||
expect(EditImageAction.canShow(asset: asset, isInLockedView: false, serverVersion: editCapableVersion), isTrue);
|
||||
expect(EditImageAction.canShow(asset: asset, isInLockedView: true, serverVersion: editCapableVersion), isFalse);
|
||||
expect(
|
||||
EditImageAction.canShow(
|
||||
asset: asset,
|
||||
isInLockedView: false,
|
||||
serverVersion: const SemVer(major: 2, minor: 5, patch: 9),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
EditImageAction.canShow(
|
||||
asset: LocalAssetFactory.create(),
|
||||
isInLockedView: false,
|
||||
serverVersion: editCapableVersion,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||
import 'package:immich_mobile/presentation/actions/open_activity.action.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_ui/immich_ui.dart';
|
||||
|
||||
import '../../factories/remote_album_factory.dart';
|
||||
import '../../factories/remote_asset_factory.dart';
|
||||
import '../presentation_context.dart';
|
||||
|
||||
class _StubAlbumNotifier extends CurrentAlbumNotifier {
|
||||
_StubAlbumNotifier(this.album);
|
||||
|
||||
final RemoteAlbum? album;
|
||||
|
||||
@override
|
||||
RemoteAlbum? build() => album;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late PresentationContext context;
|
||||
|
||||
setUp(() async {
|
||||
context = await PresentationContext.create();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
context.dispose();
|
||||
});
|
||||
|
||||
Future<void> pumpAction(
|
||||
WidgetTester tester, {
|
||||
RemoteAlbum? album,
|
||||
bool isInLockedView = false,
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
await tester.pumpTestWidget(
|
||||
context,
|
||||
ActionIconButtonWidget(action: OpenActivityAction(assets: [RemoteAssetFactory.create()])),
|
||||
overrides: [
|
||||
currentRemoteAlbumProvider.overrideWith(() => _StubAlbumNotifier(album)),
|
||||
inLockedViewProvider.overrideWith((ref) => isInLockedView),
|
||||
...overrides,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
group('OpenActivityAction', () {
|
||||
testWidgets('visible in a shared album with activity enabled', (tester) async {
|
||||
await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: true));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden when there is no current album', (tester) async {
|
||||
await pumpAction(tester, album: null);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden when the album is not shared', (tester) async {
|
||||
await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: false, isActivityEnabled: true));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden when activity is disabled', (tester) async {
|
||||
await pumpAction(tester, album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: false));
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hidden in the locked view', (tester) async {
|
||||
await pumpAction(
|
||||
tester,
|
||||
album: RemoteAlbumFactory.create(isShared: true, isActivityEnabled: true),
|
||||
isInLockedView: true,
|
||||
);
|
||||
|
||||
expect(find.byType(ImmichIconButton), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
LocalAsset createLocalAsset({
|
||||
String? remoteId,
|
||||
@@ -1238,4 +1239,438 @@ void main() {
|
||||
expect(nonArchivedWidgets, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionButtonBuilder.getViewerBottomBarTypes', () {
|
||||
test('should return correct button types for shared album with activity', () {
|
||||
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.viewer,
|
||||
);
|
||||
|
||||
const expectedTypes = [
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.addTo,
|
||||
ActionButtonType.openActivity,
|
||||
ActionButtonType.likeActivity,
|
||||
];
|
||||
|
||||
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
|
||||
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
|
||||
|
||||
expect(bottomBarTypes, expectedTypes);
|
||||
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
|
||||
});
|
||||
|
||||
test('should return correct button types for local only asset', () {
|
||||
final localAsset = createLocalAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: localAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
|
||||
);
|
||||
|
||||
const expectedTypes = [ActionButtonType.share, ActionButtonType.upload, ActionButtonType.deleteLocal];
|
||||
|
||||
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
|
||||
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
|
||||
|
||||
expect(bottomBarTypes, expectedTypes);
|
||||
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
|
||||
});
|
||||
|
||||
test('should return correct button types for locked view', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: false,
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
const expectedTypes = [
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.removeFromLockFolder,
|
||||
ActionButtonType.deletePermanent,
|
||||
];
|
||||
|
||||
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
|
||||
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
|
||||
|
||||
expect(bottomBarTypes, expectedTypes);
|
||||
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
|
||||
});
|
||||
|
||||
test('should return correct button types for remote only asset', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
|
||||
);
|
||||
|
||||
const expectedTypes = [
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.editImage,
|
||||
ActionButtonType.addTo,
|
||||
ActionButtonType.delete,
|
||||
];
|
||||
|
||||
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
|
||||
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
|
||||
|
||||
expect(bottomBarTypes, expectedTypes);
|
||||
expect(bottomBarTypes.any(kebabTypes.contains), isFalse);
|
||||
});
|
||||
|
||||
test('trashed asset (no album) keeps restore and permanent delete in bottom bar', () {
|
||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime.now());
|
||||
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,
|
||||
);
|
||||
|
||||
const expectedTypes = [
|
||||
ActionButtonType.share,
|
||||
ActionButtonType.addTo,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
];
|
||||
|
||||
expect(ActionButtonBuilder.getViewerBottomBarTypes(context), expectedTypes);
|
||||
});
|
||||
|
||||
test('trashed asset in shared activity album still surfaces restore and permanent delete', () {
|
||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime.now());
|
||||
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.viewer,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
|
||||
|
||||
expect(bottomBarTypes, contains(ActionButtonType.restoreTrash));
|
||||
expect(bottomBarTypes, contains(ActionButtonType.deletePermanent));
|
||||
expect(bottomBarTypes, isNot(contains(ActionButtonType.openActivity)));
|
||||
expect(bottomBarTypes, isNot(contains(ActionButtonType.likeActivity)));
|
||||
});
|
||||
|
||||
test('5th bottom-bar candidate overflows into the kebab', () {
|
||||
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.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
|
||||
);
|
||||
|
||||
final bottomBarTypes = ActionButtonBuilder.getViewerBottomBarTypes(context);
|
||||
final kebabTypes = ActionButtonBuilder.getViewerKebabMenuTypes(context);
|
||||
|
||||
expect(bottomBarTypes.length, 4);
|
||||
expect(bottomBarTypes, isNot(contains(ActionButtonType.delete)));
|
||||
expect(kebabTypes, contains(ActionButtonType.delete));
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionButtonType.shouldShow editImage button', () {
|
||||
final editableRemote = createRemoteAsset();
|
||||
|
||||
test('should show for editable remote asset on a recent server', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: editableRemote,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
|
||||
);
|
||||
|
||||
expect(ActionButtonType.editImage.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when local-only (LocalAsset is not editable)', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: createLocalAsset(),
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
|
||||
);
|
||||
|
||||
expect(ActionButtonType.editImage.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when server version is below 2.6', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: editableRemote,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 5, patch: 9),
|
||||
);
|
||||
|
||||
expect(ActionButtonType.editImage.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show in locked view', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: editableRemote,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
serverVersion: const SemVer(major: 2, minor: 6, patch: 0),
|
||||
);
|
||||
|
||||
expect(ActionButtonType.editImage.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionButtonType.shouldShow addTo button', () {
|
||||
test('should show when not locked and asset has remote', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: createRemoteAsset(),
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.addTo.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show in locked view', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: createRemoteAsset(),
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: true,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.addTo.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show for local-only asset', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: createLocalAsset(),
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.addTo.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionButtonType.shouldShow openActivity button', () {
|
||||
final asset = createRemoteAsset();
|
||||
|
||||
test('should show in a shared activity-enabled album', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true),
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.openActivity.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when no album', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when album not shared', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: false),
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when activity disabled', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: createRemoteAlbum(isActivityEnabled: false, isShared: true),
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show in locked view', () {
|
||||
final context = ActionButtonContext(
|
||||
asset: asset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: true,
|
||||
currentAlbum: createRemoteAlbum(isActivityEnabled: true, isShared: true),
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.openActivity.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionButtonType.showsAt', () {
|
||||
ActionButtonContext ctx({RemoteAlbum? album, TimelineOrigin origin = TimelineOrigin.main}) => ActionButtonContext(
|
||||
asset: createRemoteAsset(),
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: album,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
timelineOrigin: origin,
|
||||
);
|
||||
|
||||
group('editImage', () {
|
||||
test('hidden from bottom bar when album is shared', () {
|
||||
final context = ctx(album: createRemoteAlbum(isShared: true));
|
||||
expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, context), isFalse);
|
||||
});
|
||||
|
||||
test('shown in bottom bar when album is not shared', () {
|
||||
final context = ctx(album: createRemoteAlbum(isShared: false));
|
||||
expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, context), isTrue);
|
||||
});
|
||||
|
||||
test('shown in bottom bar when there is no album', () {
|
||||
expect(ActionButtonType.editImage.showsAt(ButtonPosition.bottomBar, ctx()), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('openActivity / likeActivity', () {
|
||||
test('hidden from bottom bar when viewing from trash', () {
|
||||
final context = ctx(origin: TimelineOrigin.trash);
|
||||
expect(ActionButtonType.openActivity.showsAt(ButtonPosition.bottomBar, context), isFalse);
|
||||
expect(ActionButtonType.likeActivity.showsAt(ButtonPosition.bottomBar, context), isFalse);
|
||||
});
|
||||
|
||||
test('shown in bottom bar outside trash', () {
|
||||
expect(ActionButtonType.openActivity.showsAt(ButtonPosition.bottomBar, ctx()), isTrue);
|
||||
expect(ActionButtonType.likeActivity.showsAt(ButtonPosition.bottomBar, ctx()), isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user