Compare commits

..

14 Commits

Author SHA1 Message Date
Ben Beckford ffdb62fb39 feat(web): memory page dark styling 2026-06-23 13:30:29 -07:00
Ben Beckford 427bcb1e35 Merge branch 'main' into feat/memories-view 2026-06-23 13:04:33 -07:00
Ben Beckford dd1f5acd48 feat(web): animated memory previews 2026-06-04 13:39:53 -07:00
Ben Beckford 209dcb38c5 fix(web): memories resetting 2026-06-04 13:38:48 -07:00
Ben Beckford 03153c864e fix(web): memories infinitely loading on 0 results 2026-06-04 12:42:18 -07:00
Ben Beckford 545db90d13 Merge branch 'main' into feat/memories-view 2026-06-04 12:04:44 -07:00
Timon 1b451f3d07 Merge branch 'main' into feat/memories-view 2026-06-03 09:46:49 +02:00
Ben Beckford 93f19b86a1 refactor(web): move timline memory manager filter 2026-06-02 00:25:58 -07:00
Ben Beckford c287f9a49a fix(web): avoid unnecessary memory refreshes 2026-06-01 23:43:15 -07:00
Ben Beckford 61f37b233d improve memories web ui 2026-06-01 23:38:00 -07:00
Ben Beckford eee20881dd feat(mobile): memories view 2026-06-01 23:05:49 -07:00
Ben Beckford bb8bfcdf1e improve memories ui 2026-05-31 18:24:18 -07:00
Ben Beckford 3f1b8e1d9b paginate searchMemories 2026-05-28 23:54:14 -07:00
Ben Beckford 6e78d6e131 wip web memories view 2026-05-28 14:11:15 -07:00
81 changed files with 1147 additions and 1134 deletions
-2
View File
@@ -45,8 +45,6 @@ jobs:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
- 'packages/plugin-core/**'
- 'packages/plugin-sdk/**'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
-3
View File
@@ -1507,9 +1507,6 @@
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_enabled_list_tile_content": "Immich uses notifications for background backup. Manage them in your device settings.",
"notification_enabled_list_tile_open_button": "Open settings",
"notification_enabled_list_tile_title": "Notifications enabled",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
-1
View File
@@ -7,7 +7,6 @@ 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)
@@ -1,109 +0,0 @@
#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;
}
@@ -1,19 +0,0 @@
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,9 +12,7 @@ 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
@@ -76,11 +74,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
companion object {
val CANCELLED = Result.success<Map<String, Long>?>(null)
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
// "Load original" decodes a raw at full res, and the orientation pass then walks every pixel, so
// cap the decode resolution to keep that bounded on huge DNGs. This only trims pixels on very
// large raws - they still come out upright, just downsampled.
const val MAX_RAW_DECODE_PIXELS = 24_000_000L
}
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
@@ -188,133 +181,35 @@ 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 {
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.
signal.throwIfCanceled()
val res = bitmap.toNativeBuffer()
signal.throwIfCanceled()
callback(Result.success(res))
} catch (e: Exception) {
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
}
}
// 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> {
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
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) {
// A "load original" request is unsized -> a full-res decode. For raw, cap it so the later
// orientation pass stays within a safe pixel budget.
val bitmap = if (handleRaw && (size.width <= 0 || size.height <= 0)) {
decodeRawCapped(uri, signal)
} else {
decodeSource(uri, size, signal)
}
return bitmap to orientation
return decodeSource(uri, size, signal)
}
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return 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
}
// Full-res raw decode for "load original", sampled down to MAX_RAW_DECODE_PIXELS (power of two).
// Caps resolution only; the caller still rotates the result, so even huge raws end up upright.
@RequiresApi(Build.VERSION_CODES.Q)
private fun decodeRawCapped(uri: Uri, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) { decoder, info, _ ->
val pixels = info.size.width.toLong() * info.size.height.toLong()
var sample = 1
while (pixels / (sample.toLong() * sample) > MAX_RAW_DECODE_PIXELS) {
sample *= 2
}
if (sample > 1) {
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
}
}
// 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), falling back to Skia for any config the native path can't take.
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
signal.throwIfCanceled()
// Force ARGB_8888 so both the native pass and the Skia fallback are 4 bytes/pixel: 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) {
return mapOf(
"pointer" to pointer,
"width" to info[0].toLong(),
"height" to info[1].toLong(),
"rowBytes" to info[2].toLong()
)
}
// Native path declined (unsupported config) -> rotate via Skia, then copy out.
val matrix = matrixForExifOrientation(orientation) ?: return src.toNativeBuffer()
return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true).toNativeBuffer()
} finally {
if (!src.isRecycled) src.recycle()
}
}
// EXIF orientation (1-8) -> transform matrix, or null when no rotation/flip is needed.
private fun matrixForExifOrientation(orientation: Int): Matrix? {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> matrix.apply { postRotate(270f); postScale(-1f, 1f) }
ExifInterface.ORIENTATION_TRANSVERSE -> matrix.apply { postRotate(90f); postScale(-1f, 1f) }
else -> return null
}
return matrix
}
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
+14 -25
View File
@@ -3,35 +3,33 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
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';
class AssetService {
final RemoteAssetRepository _remoteRepository;
final DriftLocalAssetRepository _localRepository;
final AssetApiRepository _apiRepository;
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
Future<BaseAsset?> getAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localRepository.get(id) : _remoteRepository.get(id);
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
}
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id);
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
}
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
return _localRepository.getByChecksum(checksum);
return _localAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
return _remoteRepository.getByChecksum(checksum);
return _remoteAssetRepository.getByChecksum(checksum);
}
Future<RemoteAsset?> getRemoteAsset(String id) {
return _remoteRepository.get(id);
return _remoteAssetRepository.get(id);
}
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
@@ -39,7 +37,7 @@ class AssetService {
return const [];
}
final stack = await _remoteRepository.getStackChildren(asset);
final stack = await _remoteAssetRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack];
}
@@ -50,31 +48,22 @@ class AssetService {
}
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
return _remoteRepository.getExif(id);
return _remoteAssetRepository.getExif(id);
}
Future<List<(String, String)>> getPlaces(String userId) {
return _remoteRepository.getPlaces(userId);
return _remoteAssetRepository.getPlaces(userId);
}
Future<(int local, int remote)> getAssetCounts() async {
return (await _localRepository.getCount(), await _remoteRepository.getCount());
return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount());
}
Future<int> getLocalHashedCount() {
return _localRepository.getHashedCount();
return _localAssetRepository.getHashedCount();
}
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
if (remoteIds.isEmpty) {
return;
}
await _apiRepository.updateFavorite(remoteIds, isFavorite);
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
}
@@ -13,6 +13,10 @@ class DriftMemoryService {
return _repository.getAll(ownerId);
}
Future<List<DriftMemory>> getAllMemories(String ownerId) {
return _repository.getAll(ownerId, onlyToday: false);
}
Future<DriftMemory?> get(String memoryId) {
return _repository.get(memoryId);
}
@@ -9,10 +9,7 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftMemoryRepository(this._db) : super(_db);
Future<List<DriftMemory>> getAll(String ownerId) async {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true}) async {
final query =
_db.select(_db.memoryEntity).join([
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
@@ -24,10 +21,17 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
),
])
..where(_db.memoryEntity.ownerId.equals(ownerId))
..where(_db.memoryEntity.deletedAt.isNull())
..where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc))
..where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc))
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
..where(_db.memoryEntity.deletedAt.isNull());
if (onlyToday) {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
query.where(_db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc));
query.where(_db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc));
}
query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
final rows = await query.get();
if (rows.isEmpty) {
+1 -1
View File
@@ -112,7 +112,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
}
if (index == kPhotoTabIndex) {
ref.invalidate(driftMemoryFutureProvider);
ref.invalidate(driftMemoryLaneProvider);
}
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
@@ -1,6 +1,5 @@
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';
class ActionScope {
@@ -22,11 +21,3 @@ abstract class BaseAction {
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>();
}
@@ -1,27 +0,0 @@
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/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class AssetDebugAction extends AssetAction<BaseAsset> {
const AssetDebugAction({required super.assets});
@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)));
}
@@ -1,40 +0,0 @@
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_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool shouldFavorite;
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
@override
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@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);
}
}
@@ -1,24 +0,0 @@
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});
@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);
scope.ref.read(multiSelectProvider.notifier).reset();
}
}
@@ -11,7 +11,7 @@ class MainTimelinePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
@@ -7,9 +7,12 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -134,7 +137,12 @@ class _CollectionCards extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
children: [
_PeopleCollectionCard(),
_PlacesCollectionCard(),
_LocalAlbumsCollectionCard(),
_MemoriesCollectionCard(),
],
),
),
);
@@ -328,6 +336,76 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
}
}
class _MemoriesCollectionCard extends ConsumerWidget {
const _MemoriesCollectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final memories = ref.watch(driftAllMemoriesProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const DriftMemoryListRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: memories.widgetWhen(
onLoading: () => const Center(child: CircularProgressIndicator()),
onData: (memories) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: memories.take(4).map((memory) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'memories'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
@visibleForTesting
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
@@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@@ -50,7 +49,7 @@ class DriftMemoryPage extends HookConsumerWidget {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
restoreEdgeToEdge();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
};
});
@@ -329,7 +328,7 @@ class DriftMemoryPage extends HookConsumerWidget {
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
restoreEdgeToEdge();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -0,0 +1,104 @@
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/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class DriftMemoryListPage extends ConsumerStatefulWidget {
const DriftMemoryListPage({super.key});
@override
ConsumerState<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
}
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
bool _onlyFavorites = false;
@override
Widget build(BuildContext context) {
final memories = ref.watch(driftAllMemoriesProvider);
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
appBar: AppBar(
title: Text('memories'.tr()),
actions: [
IconButton(
icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline),
onPressed: () {
setState(() => _onlyFavorites = !_onlyFavorites);
},
),
],
),
body: SafeArea(
child: memories.when(
data: (memories) {
if (_onlyFavorites) {
memories = memories.where((memory) => memory.isSaved).toList();
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: constraints.maxWidth > 600 ? 4 : 2,
childAspectRatio: 0.5625,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
padding: const EdgeInsets.all(16),
itemCount: memories.length,
itemBuilder: (context, index) => GestureDetector(
onTap: () {
if (memories[index].assets.isNotEmpty) {
DriftMemoryPage.setMemory(ref, memories[index]);
}
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
},
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
child: AbsorbPointer(
child: Thumbnail.remote(
remoteId: memories[index].assets[0].id,
thumbhash: memories[index].assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
),
),
),
Positioned(
bottom: 16,
left: 16,
child: Text(
DateFormat.yMMMMd().format(memories[index].memoryAt),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15),
),
),
if (memories[index].isSaved)
const Positioned(
top: 16,
right: 16,
child: Icon(Icons.favorite, color: Colors.white, size: 24),
),
],
),
),
);
},
error: (error, stack) => const Text("Error loading memories"),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
},
);
}
}
@@ -19,7 +19,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -77,7 +76,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
unawaited(restoreEdgeToEdge());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@@ -256,7 +255,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _onTapUp() async {
await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge());
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -0,0 +1,36 @@
import 'dart:async';
import 'package:flutter/material.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';
class AdvancedInfoActionButton extends ConsumerWidget {
final ActionSource source;
final bool iconOnly;
final bool menuItem;
const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
unawaited(ref.read(actionProvider.notifier).troubleshoot(source, context));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton(
maxWidth: 115.0,
iconData: Icons.help_outline_rounded,
label: "troubleshoot".t(context: context),
iconOnly: iconOnly,
menuItem: menuItem,
onPressed: () => _onTap(context, ref),
);
}
}
@@ -23,7 +23,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
@@ -129,7 +128,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
unawaited(restoreEdgeToEdge());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@@ -252,8 +251,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _setSystemUIMode(bool controls, bool details) {
final immersive = !controls || (CurrentPlatform.isIOS && details);
unawaited(immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge());
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}
@override
@@ -12,7 +12,6 @@ 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/action_button.utils.dart';
import 'package:immich_ui/immich_ui.dart';
class ViewerKebabMenu extends ConsumerWidget {
const ViewerKebabMenu({super.key, this.originalTheme});
@@ -50,9 +49,9 @@ class ViewerKebabMenu extends ConsumerWidget {
timelineOrigin: timelineOrigin,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
return ImmichMenu(
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
@@ -63,7 +62,7 @@ class ViewerKebabMenu extends ConsumerWidget {
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
children: [
menuChildren: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 150),
child: Theme(
@@ -2,11 +2,12 @@ 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/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -14,9 +15,9 @@ 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/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_ui/immich_ui.dart';
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
const ViewerTopAppBar({super.key});
@@ -30,6 +31,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final album = ref.watch(currentRemoteAlbumProvider);
final user = ref.watch(currentUserProvider);
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
final isInLockedView = ref.watch(inLockedViewProvider);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
@@ -43,7 +46,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
final originalTheme = context.themeData;
final assetForAction = [asset];
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
@@ -61,7 +63,10 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
if (asset.hasRemote && isOwner && !asset.isFavorite)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),
];
@@ -102,13 +107,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
leading: const _AppBarBackButton(),
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
trailing: !showingDetails && !isReadonlyModeEnabled
? ImmichColorOverride(
color: Colors.white,
child: Row(
mainAxisSize: MainAxisSize.min,
children: isInLockedView ? lockedViewActions : actions,
),
)
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
: null,
),
),
@@ -3,14 +3,12 @@ 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.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_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';
@@ -76,9 +74,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25,
@@ -89,7 +84,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
@@ -4,9 +4,6 @@ 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.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.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';
@@ -18,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
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/unfavorite_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';
@@ -67,9 +65,6 @@ class FavoriteBottomSheet extends ConsumerWidget {
ref.read(multiSelectProvider.notifier).reset();
}
final assets = multiselect.selectedAssets.toList(growable: false);
final actions = [FavoriteAction(assets: assets)];
return BaseBottomSheet(
initialChildSize: 0.4,
maxChildSize: 0.7,
@@ -78,7 +73,7 @@ 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 UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
@@ -3,9 +3,8 @@ 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.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
@@ -25,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
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/setting.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';
@@ -56,6 +56,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
Widget build(BuildContext context) {
final multiselect = ref.watch(multiSelectProvider);
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final tagsEnabled = ref.watch(
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
);
@@ -83,9 +84,6 @@ 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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: widget.minChildSize ?? 0.15,
@@ -93,7 +91,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
maxChildSize: 0.85,
shouldCloseOnMinExtent: false,
actions: [
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[
const AdvancedInfoActionButton(source: ActionSource.timeline),
],
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
@@ -3,15 +3,13 @@ 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.widget.dart';
import 'package:immich_mobile/presentation/actions/favorite.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/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/remove_from_album_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -85,9 +83,6 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
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)];
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.22,
@@ -101,7 +96,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
if (ownsAlbum) ...[
const ArchiveActionButton(source: ActionSource.timeline),
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
const FavoriteActionButton(source: ActionSource.timeline),
],
const DownloadActionButton(source: ActionSource.timeline),
if (ownsAlbum) ...[
@@ -14,7 +14,7 @@ class DriftMemoryLane extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
final memories = memoryLaneProvider.value ?? const [];
if (memories.isEmpty) {
return const SizedBox.shrink();
@@ -5,7 +5,6 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
final localAssetRepository = Provider<DriftLocalAssetRepository>(
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
@@ -21,9 +20,8 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
final assetServiceProvider = Provider(
(ref) => AssetService(
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
localRepository: ref.watch(localAssetRepository),
apiRepository: ref.watch(assetApiRepositoryProvider),
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
),
);
@@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
);
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
@@ -29,3 +29,13 @@ final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>(
final service = ref.watch(driftMemoryServiceProvider);
return service.getMemoryLane(userId);
});
final driftAllMemoriesProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
}
final service = ref.watch(driftMemoryServiceProvider);
return service.getAllMemories(userId);
});
+2
View File
@@ -52,6 +52,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
import 'package:immich_mobile/presentation/pages/drift_map.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart';
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
@@ -191,6 +192,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
+16
View File
@@ -754,6 +754,22 @@ class DriftMapRouteArgs {
int get hashCode => key.hashCode ^ initialLocation.hashCode;
}
/// generated route for
/// [DriftMemoryListPage]
class DriftMemoryListRoute extends PageRouteInfo<void> {
const DriftMemoryListRoute({List<PageRouteInfo>? children})
: super(DriftMemoryListRoute.name, initialChildren: children);
static const String name = 'DriftMemoryListRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftMemoryListPage();
},
);
}
/// generated route for
/// [DriftMemoryPage]
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
+10 -6
View File
@@ -1,14 +1,14 @@
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.widget.dart';
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
@@ -185,14 +185,18 @@ enum ActionButtonType {
};
}
Widget buildButton(
ConsumerWidget buildButton(
ActionButtonContext context, [
BuildContext? buildContext,
bool iconOnly = false,
bool menuItem = false,
]) {
return switch (this) {
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
ActionButtonType.advancedInfo => AdvancedInfoActionButton(
source: context.source,
iconOnly: iconOnly,
menuItem: menuItem,
),
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
ActionButtonType.shareLink => ShareLinkActionButton(
source: context.source,
@@ -330,7 +334,7 @@ class ActionButtonBuilder {
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).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 +350,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, false, true).build(buildContext, ref));
lastGroup = type.kebabMenuGroup;
}
-14
View File
@@ -1,14 +0,0 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// Restore the system bars and return to edge-to-edge layout.
///
/// On Android 15+/API 36 edge-to-edge is enforced, so calling
/// setEnabledSystemUIMode(edgeToEdge) does NOT re-show bars that an immersive
/// mode (immersive / immersiveSticky) previously hid. Explicitly request all
/// overlays first, then return to edge-to-edge layout.
Future<void> restoreEdgeToEdge() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
@@ -48,14 +48,6 @@ class NotificationSetting extends HookConsumerWidget {
showPermissionsDialog();
}
}),
)
else
SettingsButtonListTile(
icon: Icons.notifications_active_outlined,
title: 'notification_enabled_list_tile_title'.tr(),
subtileText: 'notification_enabled_list_tile_content'.tr(),
buttonText: 'notification_enabled_list_tile_open_button'.tr(),
onButtonTap: () => openAppSettings(),
),
];
+1
View File
@@ -482,6 +482,7 @@ Class | Method | HTTP request | Description
- [MemoryCreateDto](doc//MemoryCreateDto.md)
- [MemoryResponseDto](doc//MemoryResponseDto.md)
- [MemorySearchOrder](doc//MemorySearchOrder.md)
- [MemorySearchResponseDto](doc//MemorySearchResponseDto.md)
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
- [MemoryType](doc//MemoryType.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
+1
View File
@@ -203,6 +203,7 @@ part 'model/memories_update.dart';
part 'model/memory_create_dto.dart';
part 'model/memory_response_dto.dart';
part 'model/memory_search_order.dart';
part 'model/memory_search_response_dto.dart';
part 'model/memory_statistics_response_dto.dart';
part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
+20 -5
View File
@@ -265,6 +265,9 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -292,6 +295,9 @@ class MemoriesApi {
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (page != null) {
queryParams.addAll(_queryParams('', 'page', page));
}
if (size != null) {
queryParams.addAll(_queryParams('', 'size', size));
}
@@ -331,6 +337,9 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -434,6 +443,9 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -461,6 +473,9 @@ class MemoriesApi {
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (page != null) {
queryParams.addAll(_queryParams('', 'page', page));
}
if (size != null) {
queryParams.addAll(_queryParams('', 'size', size));
}
@@ -500,6 +515,9 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -513,11 +531,8 @@ class MemoriesApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<MemoryResponseDto>') as List)
.cast<MemoryResponseDto>()
.toList(growable: false);
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto;
}
return null;
}
+2
View File
@@ -451,6 +451,8 @@ class ApiClient {
return MemoryResponseDto.fromJson(value);
case 'MemorySearchOrder':
return MemorySearchOrderTypeTransformer().decode(value);
case 'MemorySearchResponseDto':
return MemorySearchResponseDto.fromJson(value);
case 'MemoryStatisticsResponseDto':
return MemoryStatisticsResponseDto.fromJson(value);
case 'MemoryType':
+120
View File
@@ -0,0 +1,120 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MemorySearchResponseDto {
/// Returns a new [MemorySearchResponseDto] instance.
MemorySearchResponseDto({
required this.hasNextPage,
this.items = const [],
required this.total,
});
/// Whether there are more pages
bool hasNextPage;
List<MemoryResponseDto> items;
/// Total number of matching memories
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int total;
@override
bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto &&
other.hasNextPage == hasNextPage &&
_deepEquality.equals(other.items, items) &&
other.total == total;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(hasNextPage.hashCode) +
(items.hashCode) +
(total.hashCode);
@override
String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'hasNextPage'] = this.hasNextPage;
json[r'items'] = this.items;
json[r'total'] = this.total;
return json;
}
/// Returns a new [MemorySearchResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MemorySearchResponseDto? fromJson(dynamic value) {
upgradeDto(value, "MemorySearchResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MemorySearchResponseDto(
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
items: MemoryResponseDto.listFromJson(json[r'items']),
total: mapValueOfType<int>(json, r'total')!,
);
}
return null;
}
static List<MemorySearchResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MemorySearchResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MemorySearchResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MemorySearchResponseDto> mapFromJson(dynamic json) {
final map = <String, MemorySearchResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MemorySearchResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map
static Map<String, List<MemorySearchResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MemorySearchResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'hasNextPage',
'items',
'total',
};
}
-1
View File
@@ -1,4 +1,3 @@
export 'src/color_override.dart';
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
+2 -2
View File
@@ -217,8 +217,8 @@ class MediumRepositoryContext {
}
Future<AssetFaceEntityData> newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) {
imageWidth ??= TestUtils.randInt(999) + 2;
imageHeight ??= TestUtils.randInt(999) + 2;
imageWidth ??= TestUtils.randInt(999) + 1;
imageHeight ??= TestUtils.randInt(999) + 1;
final x1 = TestUtils.randInt(imageWidth - 1);
final y1 = TestUtils.randInt(imageHeight - 1);
-12
View File
@@ -34,7 +34,6 @@ class RepositoryMocks {
class ServiceMocks {
final PartnerStub partner = PartnerStub(MockPartnerService());
final UserStub user = UserStub(MockUserService());
final asset = AssetStub(MockAssetService());
ServiceMocks() {
resetAll();
@@ -44,10 +43,8 @@ class ServiceMocks {
_registerFallbacks();
partner.reset();
user.reset();
asset.reset();
_stubUserService();
_stubPartnerService();
_stubAssetService();
}
void _stubUserService() {
@@ -66,10 +63,6 @@ class ServiceMocks {
when(partner.create).thenAnswer((_) async {});
when(partner.delete).thenAnswer((_) async {});
}
void _stubAssetService() {
when(asset.updateFavorite).thenAnswer((_) async {});
}
}
void _registerFallbacks() {
@@ -126,8 +119,3 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
Future<String?> Function() get createProfileImage =>
() => service.createProfileImage(any(), any());
}
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
Future<void> Function() get updateFavorite =>
() => service.updateFavorite(any(), any());
}
@@ -1,54 +0,0 @@
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';
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
await StoreService.I.put(StoreKey.advancedTroubleshooting, true);
});
tearDown(() {
context.dispose();
});
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,
);
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,
);
expect(find.byType(ImmichIconButton), findsNothing);
});
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,
);
expect(find.byType(ImmichIconButton), findsNothing);
});
});
}
@@ -1,82 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../factories/remote_asset_factory.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
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();
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
});
testWidgets('unfavorite the eligible owned assets', (tester) async {
final asset = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
});
testWidgets('ignores assets owned by someone else', (tester) async {
final mine = owned();
final theirs = RemoteAssetFactory.create();
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
});
testWidgets('batches every eligible owned asset into a single call', (tester) async {
final first = owned();
final second = owned();
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
});
testWidgets('skips owned assets already in the target state', (tester) async {
final stale = owned();
final alreadyFavorite = owned(isFavorite: true);
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
});
testWidgets('shows a confirmation snackbar on success', (tester) async {
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
await tester.pumpUntilFound(find.byType(SnackBar));
expect(find.byType(SnackBar), findsOneWidget);
});
});
}
@@ -5,25 +5,32 @@ 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:immich_mobile/providers/user.provider.dart';
import 'package:mocktail/mocktail.dart';
import '../../factories/user_factory.dart';
import '../../mocks.dart';
import '../../presentation_context.dart';
void main() {
late PresentationContext context;
late UserDto currentUser;
final mocks = ServiceMocks();
setUp(() async {
currentUser = UserFactory.createDto();
context = await PresentationContext.create();
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
});
tearDown(() {
context.dispose();
tearDown(() async {
mocks.resetAll();
await context.dispose();
});
List<Override> overrides({List<User> candidates = const []}) => [
...context.overrides,
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)),
partnerServiceProvider.overrideWithValue(mocks.partner.service),
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
];
@@ -36,9 +43,7 @@ void main() {
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(() => mocks.partner.service.create(sharedById: currentUser.id, sharedWithId: candidate.id)).called(1);
});
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
@@ -46,7 +51,7 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.create);
verifyNever(mocks.partner.create);
});
});
@@ -60,9 +65,7 @@ void main() {
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(() => mocks.partner.service.delete(sharedById: currentUser.id, sharedWithId: partner.id)).called(1);
});
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
@@ -74,7 +77,7 @@ void main() {
await tester.tap(find.byType(TextButton).first); // cancel
await tester.pumpAndSettle();
verifyNever(context.mocks.partner.delete);
verifyNever(mocks.partner.delete);
});
});
}
@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/presentation/actions/action.widget.dart';
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';
class _FakeAction extends BaseAction {
_FakeAction({this.visible = true, this.error});
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 {
ran = true;
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
if (error != null) {
throw error!;
}
}
}
void main() {
late PresentationContext context;
setUp(() async {
context = await PresentationContext.create();
});
tearDown(() {
context.dispose();
});
List<Override> seededOverrides() => [
...context.overrides,
multiSelectProvider.overrideWith(
() => MultiSelectNotifier(
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
),
),
];
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
late ActionScope scope;
late ProviderContainer container;
await tester.pumpTestWidget(
Consumer(
builder: (innerContext, ref, _) {
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
container = ProviderScope.containerOf(innerContext, listen: false);
return const SizedBox.shrink();
},
),
overrides: seededOverrides(),
);
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);
expect(inner.ran, isTrue);
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
expect(container.read(multiSelectProvider).isEnabled, isFalse);
});
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);
await expectLater(TimelineAction(action: inner).onAction(scope), 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,
);
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
});
}
@@ -13,7 +13,7 @@ void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() => context.dispose());
tearDown(() async => await context.dispose());
group('PartnerSharedByList', () {
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
+10 -11
View File
@@ -23,7 +23,7 @@ import 'mocks.dart';
class PresentationContext {
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
setup();
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
static const String serverEndpoint = 'http://localhost:3000';
@@ -46,14 +46,10 @@ class PresentationContext {
return PresentationContext._(user: UserFactory.createDto());
}
void setup() {
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
}
void dispose() {
addTearDown(() {
mocks.resetAll();
});
Future<void> dispose() async {
// TODO: Dispose the store and database after each test.
// This is currently not possible because the store is a singleton and is used across tests.
// Refactor the store to be created per test to allow proper disposal.
}
}
@@ -77,7 +73,7 @@ extension PumpPresentationWidget on WidgetTester {
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
home: Scaffold(body: widget),
home: Material(child: widget),
),
),
),
@@ -87,7 +83,10 @@ extension PumpPresentationWidget on WidgetTester {
}
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
await pumpTestWidget(
Scaffold(body: ActionIconButtonWidget(action: action)),
overrides: overrides,
);
await tap(find.byType(ImmichIconButton));
await pump();
}
+49 -4
View File
@@ -7171,6 +7171,17 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -7196,10 +7207,7 @@
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/MemoryResponseDto"
},
"type": "array"
"$ref": "#/components/schemas/MemorySearchResponseDto"
}
}
},
@@ -7340,6 +7348,17 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -19955,6 +19974,32 @@
],
"type": "string"
},
"MemorySearchResponseDto": {
"properties": {
"hasNextPage": {
"description": "Whether there are more pages",
"type": "boolean"
},
"items": {
"items": {
"$ref": "#/components/schemas/MemoryResponseDto"
},
"type": "array"
},
"total": {
"description": "Total number of matching memories",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
}
},
"required": [
"hasNextPage",
"items",
"total"
],
"type": "object"
},
"MemoryStatisticsResponseDto": {
"properties": {
"total": {
+1 -10
View File
@@ -222,16 +222,7 @@
"name": "assetLock",
"title": "Move to locked folder",
"description": "Change visibility to locked",
"types": ["AssetV1"],
"schema": {
"properties": {
"inverse": {
"title": "Inverse",
"description": "When true will unarchive any archived assets",
"type": "boolean"
}
}
}
"types": ["AssetV1"]
},
{
"name": "assetTimeline",
+2 -2
View File
@@ -5,8 +5,8 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm"
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
"author": "",
+27
View File
@@ -0,0 +1,27 @@
// keep in sync with plugin-sdk/host-functions.ts';
declare module 'extism:host' {
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
// keep in sync with manifest.json
declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function assetLocationFilter(): I32;
export function assetTypeFilter(): I32;
// updates
export function assetFavorite(): I32;
export function assetVisibility(): I32;
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}
+166 -140
View File
@@ -1,157 +1,183 @@
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts';
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
const wrapper = getWrapper<Manifest>();
type AssetFileFilterConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
const { asset } = data;
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
});
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
};
export const assetLocationFilter = () => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
};
export const assetTypeFilter = () => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
};
export const assetVisibility = () => {
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
}));
};
export const assetArchive = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
default: {
return {};
}
}
});
export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
export const assetLock = wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
}
});
};
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
});
export const assetLock = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
};
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
}
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
return {};
});
};
+1 -1
View File
@@ -13,7 +13,7 @@
"skipLibCheck": true, // Skip type checking of declaration files
"strict": true, // Enable all strict type-checking options
"target": "es2020", // Specify ECMAScript target version
"types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
"types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
},
"exclude": [
"node_modules" // Exclude the node_modules directory
+1 -2
View File
@@ -1,12 +1,11 @@
import esbuild from 'esbuild';
esbuild.build({
entryPoints: ['src/index.ts', 'src/cli.ts'],
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: false,
minify: false,
format: 'esm',
platform: 'node',
target: ['es2020'],
});
-6
View File
@@ -21,9 +21,6 @@
"files": [
"dist"
],
"bin": {
"plugin-sdk": "./plugin-sdk.mjs"
},
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
@@ -38,8 +35,5 @@
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
},
"dependencies": {
"commander": "^15.0.0"
}
}
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env node
import "./dist/cli.js";
-43
View File
@@ -1,43 +0,0 @@
import { Command } from 'commander';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { availableFunctions } from 'src/host-functions.js';
const program = new Command('plugin-sdk');
program
.command('prepareBuild')
.description('Generate .d.ts file required for extism')
.argument(
'[manifest]',
"Path to the plugins's manifest file",
'manifest.json',
)
.option('-o --output', 'Output file for generated types', 'dist/index.d.ts')
.action((manifest: string, { output }) => {
const content = readFileSync(manifest, { encoding: 'utf-8' });
const methods = (
JSON.parse(content) as { methods: { name: string }[] }
).methods.map(({ name }) => name);
mkdirSync(dirname(output), { recursive: true });
writeFileSync(
output,
`
declare module 'extism:host' {
interface user {
${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')}
}
}
declare module 'main' {
${methods.map((method) => ` export function ${method}(): I32;`).join('\n')}
}
export type Manifest = ${content};
`,
);
});
program.parse();
+8 -12
View File
@@ -6,11 +6,14 @@ import {
type CreateAlbumDto,
} from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' {
interface user extends Record<
(typeof availableFunctions)[number],
(ptr: PTR) => I64
> {}
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
type AlbumsToAssets = {
@@ -31,13 +34,6 @@ type HostFunctionResult<T> =
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
export const availableFunctions = [
'searchAlbums',
'createAlbum',
'addAssetsToAlbum',
'addAssetsToAlbums',
] as const;
export const hostFunctions = (authToken: string) => {
const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
@@ -79,5 +75,5 @@ export const hostFunctions = (authToken: string) => {
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
} satisfies Record<(typeof availableFunctions)[number], unknown>;
};
};
+39 -91
View File
@@ -1,105 +1,53 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
type Property = {
type: 'string' | 'boolean' | 'number';
array?: boolean;
enum?: string[];
} & {
type: 'object';
properties: { [K: string]: Property };
required?: string[];
};
export const wrapper = <
T extends WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: T['type'] extends 'object'
? ConfigValue<T>
: never;
const eventConfigBefore = JSON.stringify(event.config);
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & {
name: K;
})['types'][number],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) =>
() => {
const input = Host.inputString();
const response = fn(event) ?? {};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
};
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
+14 -3
View File
@@ -1350,6 +1350,13 @@ export type MemoryResponseDto = {
/** Last update date */
updatedAt: string;
};
export type MemorySearchResponseDto = {
/** Whether there are more pages */
hasNextPage: boolean;
items: MemoryResponseDto[];
/** Total number of matching memories */
total: number;
};
export type MemoryCreateDto = {
/** Asset IDs to associate with memory */
assetIds?: string[];
@@ -4970,22 +4977,24 @@ export function reverseGeocode({ lat, lon }: {
/**
* Retrieve memories
*/
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: MemoryResponseDto[];
data: MemorySearchResponseDto;
}>(`/memories${QS.query(QS.explode({
"for": $for,
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
@@ -5010,11 +5019,12 @@ export function createMemory({ memoryCreateDto }: {
/**
* Retrieve memories statistics
*/
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
@@ -5026,6 +5036,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
-4
View File
@@ -336,10 +336,6 @@ importers:
version: 6.0.3
packages/plugin-sdk:
dependencies:
commander:
specifier: ^15.0.0
version: 15.0.0
devDependencies:
'@extism/js-pdk':
specifier: ^1.1.1
+2 -1
View File
@@ -7,6 +7,7 @@ import {
MemoryCreateDto,
MemoryResponseDto,
MemorySearchDto,
MemorySearchResponseDto,
MemoryStatisticsResponseDto,
MemoryUpdateDto,
} from 'src/dtos/memory.dto';
@@ -28,7 +29,7 @@ export class MemoryController {
'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemorySearchResponseDto> {
return this.service.search(auth, dto);
}
+10
View File
@@ -14,6 +14,7 @@ const MemorySearchSchema = z
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
page: z.coerce.number().int().min(1).optional().describe('Page number'),
order: AssetOrderWithRandomSchema.optional(),
})
.meta({ id: 'MemorySearchDto' });
@@ -75,11 +76,20 @@ const MemoryResponseSchema = z
})
.meta({ id: 'MemoryResponseDto' });
const MemorySearchResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of matching memories'),
items: z.array(MemoryResponseSchema),
hasNextPage: z.boolean().describe('Whether there are more pages'),
})
.meta({ id: 'MemorySearchResponseDto' });
export class MemorySearchDto extends createZodDto(MemorySearchSchema) {}
export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {}
export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {}
export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {}
export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {}
export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {}
export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
return {
+7 -3
View File
@@ -9,6 +9,7 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { IBulkAsset } from 'src/types';
import { paginationHelper } from 'src/utils/pagination';
@Injectable()
export class MemoryRepository implements IBulkAsset {
@@ -57,8 +58,8 @@ export class MemoryRepository implements IBulkAsset {
{ params: [DummyValue.UUID, {}] },
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
)
search(ownerId: string, dto: MemorySearchDto) {
return this.searchBuilder(ownerId, dto)
async search(ownerId: string, dto: MemorySearchDto) {
const items = await this.searchBuilder(ownerId, dto)
.select((eb) =>
jsonArrayFrom(
eb
@@ -89,8 +90,11 @@ export class MemoryRepository implements IBulkAsset {
? qb.orderBy(sql`RANDOM()`)
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
)
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1))
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
.execute();
return paginationHelper(items, dto.size ?? items.length);
}
@GenerateSql({ params: [DummyValue.UUID] })
@@ -224,7 +224,6 @@ export class PluginRepository {
error: (message) => logger.error(message),
} as Console,
logLevel: asExtismLogLevel(logger.getLogLevel()),
enableWasiOutput: true,
},
),
destroy: (plugin) => plugin.close(),
+13 -6
View File
@@ -34,21 +34,28 @@ describe(MemoryService.name, () => {
const asset = AssetFactory.create();
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
const memory2 = MemoryFactory.create({ ownerId: userId });
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
mocks.memory.search.mockResolvedValue({
items: [getForMemory(memory1), getForMemory(memory2)],
hasNextPage: false,
});
mocks.memory.statistics.mockResolvedValue({ total: 2 });
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({
items: expect.arrayContaining([
expect.objectContaining({
id: memory1.id,
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
}),
]),
);
hasNextPage: false,
total: 2,
});
});
it('should map empty result', async () => {
mocks.memory.search.mockResolvedValue([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false });
mocks.memory.statistics.mockResolvedValue({ total: 0 });
await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 });
});
});
+8 -4
View File
@@ -71,10 +71,14 @@ export class MemoryService extends BaseService {
}
async search(auth: AuthDto, dto: MemorySearchDto) {
const memories = await this.memoryRepository.search(auth.user.id, dto);
return memories
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
.map((memory: Memory) => mapMemory(memory, auth));
const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto);
const { total } = await this.memoryRepository.statistics(auth.user.id, dto);
return {
total,
items: items.map((memory: Memory) => mapMemory(memory, auth)),
hasNextPage,
};
}
statistics(auth: AuthDto, dto: MemorySearchDto) {
@@ -133,8 +133,8 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories[0]).toEqual(
expect(memories.items.length).toBe(1);
expect(memories.items[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(Date),
@@ -173,8 +173,8 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories[0]).toEqual(
expect(memories.items.length).toBe(1);
expect(memories.items[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(Date),
@@ -228,12 +228,12 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories.items.length).toBe(1);
await sut.onMemoriesCreate();
const memoriesAfter = await memoryRepo.search(user.id, {});
expect(memoriesAfter.length).toBe(1);
expect(memoriesAfter.items.length).toBe(1);
});
});
+77 -8
View File
@@ -1,9 +1,16 @@
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
import {
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
MemorySearchOrder,
MemoryType,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { toTimelineAsset } from '$lib/utils/timeline-util';
type MemoryIndex = {
@@ -20,10 +27,31 @@ export type MemoryAsset = MemoryIndex & {
nextMemory?: MemoryResponseDto;
};
const PAGE_SIZE = 250;
class MemoryManager {
#loading: Promise<void> | undefined;
#filters:
| {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}
| undefined;
#hasNextPage: boolean;
#page: number;
#total: number | undefined;
constructor() {
this.#filters = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.#total = $state(undefined);
eventManager.on({
AuthLogout: () => this.clearCache(),
AuthUserLoaded: () => this.initialize(),
@@ -37,6 +65,16 @@ class MemoryManager {
this.scheduleHourlyRefresh();
}
get filters() {
return this.#filters;
}
set filters(filters) {
this.#filters = filters;
this.clearCache();
void this.loadNextPage();
}
ready() {
return this.initialize();
}
@@ -117,22 +155,47 @@ class MemoryManager {
}
}
loadNextPage() {
if (this.#hasNextPage) {
if (this.#loading === undefined) {
this.#loading = this.load(this.#page++);
} else {
void this.#loading.then(() => (this.#loading = this.load(this.#page++)));
}
}
}
get hasNextPage() {
return this.#hasNextPage;
}
get total() {
return this.#total;
}
private clearCache() {
this.#loading = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.#total = undefined;
this.memories = [];
}
private initialize() {
if (!this.#loading) {
this.#loading = this.load();
this.#loading = this.load(this.#page++);
}
return this.#loading;
}
private async load() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
private async load(page: number) {
if (this.#filters !== undefined) {
const { items, hasNextPage, total } = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page });
this.memories.push(...items);
this.#hasNextPage = hasNextPage;
this.#total = total;
}
}
private scheduleHourlyRefresh() {
@@ -146,12 +209,18 @@ class MemoryManager {
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
this.#loading = this.load();
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
// Schedule subsequent events hourly
setInterval(
() => {
this.#loading = this.load();
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
},
60 * 60 * 1000,
);
+8 -3
View File
@@ -20,12 +20,13 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
import { DateTime } from 'luxon';
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
import { defaultLang, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { convertBCP47, langs } from '$lib/utils/i18n';
@@ -366,9 +367,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
export const memoryLaneTitle = derived(t, ($t) => {
return (memory: MemoryResponseDto) => {
const now = new Date();
if (memory.type === MemoryType.OnThisDay) {
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
const now = new Date();
const memoryDate = new Date(memory.memoryAt);
return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth()
? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } })
: DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
}
return $t('unknown');
+29 -6
View File
@@ -6,10 +6,10 @@
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui';
import { Icon, ImageCarousel } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -28,13 +28,22 @@
return targetField?.items || [];
};
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
let recents = $derived(
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
);
let people = $state(data.people.people);
let memories = $derived(
data.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.memories({ id: memory.assets[0].id }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
let people = $state(data.response.people);
let hasPeople = $derived(data.response.total > 0);
let hasPeople = $derived(data.people.total > 0);
const onPersonThumbnailReady = ({ id }: { id: string }) => {
for (const person of people) {
@@ -124,6 +133,20 @@
</div>
{/if}
{#if memories.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('memories')}</p>
<a
href={Route.memories()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<ImageCarousel items={memories} />
</div>
{/if}
{#if recents.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
+12 -4
View File
@@ -1,16 +1,24 @@
import { getAllPeople, getExploreData } from '@immich/sdk';
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc };
const [explore, people] = await Promise.all([
getExploreData(),
getAllPeople({ withHidden: false }),
memoryManager.ready(),
]);
const $t = await getFormatter();
return {
items,
response,
explore,
people,
memories: memoryManager.memories,
meta: {
title: $t('explore'),
},
@@ -1,5 +1,127 @@
<script>
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import type { PageData } from './$types';
import { Route } from '$lib/route';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import { t } from 'svelte-i18n';
import { mdiHeartOutline, mdiHeart } from '@mdi/js';
import { Button, Icon, LoadingSpinner } from '@immich/ui';
import { locale } from '$lib/stores/preferences.store';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { page } from '$app/state';
import MemoryViewer from './MemoryViewer.svelte';
import { QueryParameter } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { clearQueryParam, setQueryValue } from '$lib/utils/navigation';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true');
let lastElement: HTMLElement | undefined = $state();
const toggleFavorites = async () => {
onlyFavorites = !onlyFavorites;
memoryManager.filters = onlyFavorites ? { isSaved: true } : {};
await memoryManager.ready();
if (onlyFavorites) {
void setQueryValue('favorites', 'true');
} else {
void clearQueryParam('favorites', page.url);
}
};
const intersectionObserver = new IntersectionObserver((entries) => {
const entry = entries.find((entry) => entry.target === lastElement);
if (entry?.isIntersecting && memoryManager.hasNextPage) {
void memoryManager.loadNextPage();
}
});
$effect(() => {
if (lastElement) {
intersectionObserver.disconnect();
intersectionObserver.observe(lastElement);
}
});
const rotation = () => {
const classes = [
'rotate-[-2.5deg]',
'-rotate-2',
'rotate-[-1.5deg]',
'-rotate-1',
'rotate-[-0.5deg]',
'rotate-0',
'rotate-[0.5deg]',
'rotate-1',
'rotate-[1.5deg]',
'rotate-2',
'rotate-[2.5deg]',
];
return classes[Math.round(Math.random() * classes.length)];
};
</script>
<MemoryViewer />
{#if page.url.searchParams.has(QueryParameter.ID)}
<MemoryViewer />
{:else}
<UserPageLayout
title={data.meta.title}
description={memoryManager.total === undefined ? undefined : `(${memoryManager.total.toLocaleString($locale)})`}
>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<Button
leadingIcon={mdiHeartOutline}
size="small"
variant={onlyFavorites ? 'filled' : 'ghost'}
color="secondary"
onclick={() => toggleFavorites()}>{$t('only_favorites')}</Button
>
</div>
{/snippet}
{#if memoryManager.memories.length > 0}
<div class="grid w-full grid-cols-3 gap-7 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{#each memoryManager.memories as memory, index (memory.id)}
<a
href={Route.memories({ id: memory.assets[0].id })}
class={`relative rounded-md bg-gray-50 p-2 pb-0 shadow-md transition-all hover:scale-102 hover:rotate-0 hover:shadow-lg sm:p-5 sm:pb-0 dark:bg-gray-800 ${rotation()}`}
bind:this={
() => (index === memoryManager.memories.length - 1 ? lastElement : null),
(e) => {
if (index === memoryManager.memories.length - 1) {
lastElement = e;
}
}
}
>
{#if memory.isSaved}
<div class="absolute inset-s-2 top-2 z-2">
<Icon data-icon-favorite icon={mdiHeart} size="32" class="text-red-400" />
</div>
{/if}
<img
src={getAssetMediaUrl({ id: memory.assets[0].id })}
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
class="aspect-square object-cover brightness-75"
loading="lazy"
/>
<p class="my-2 text-center text-sm font-medium text-ellipsis capitalize hover:cursor-pointer sm:my-5">
{$memoryLaneTitle(memory)}
</p>
</a>
{/each}
</div>
{:else if memoryManager.total === undefined}
<div class="flex items-center justify-center py-16">
<LoadingSpinner size="giant" />
</div>
{/if}
</UserPageLayout>
{/if}
@@ -1,3 +1,6 @@
import { isEqual } from 'lodash-es';
import { QueryParameter } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -6,10 +9,19 @@ export const load = (async ({ url }) => {
const user = await authenticate(url);
const $t = await getFormatter();
const filters = url.searchParams.get('favorites') === 'true' ? { isSaved: true } : {};
if (
!(url.searchParams.has(QueryParameter.ID) && memoryManager.memories.length > 0) &&
!isEqual(memoryManager.filters, filters)
) {
memoryManager.filters = filters;
await memoryManager.ready();
}
return {
user,
meta: {
title: $t('memory'),
title: $t('memories'),
},
};
}) satisfies PageLoad;
@@ -82,6 +82,7 @@
let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
let previousPage = $state(Route.memories());
const handleNavigate = async (asset?: { id: string }) => {
if (assetViewerManager.isViewing) {
@@ -106,7 +107,7 @@
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(Route.photos());
const handleEscape = async () => goto(previousPage);
const handleSelectAll = () =>
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -249,7 +250,7 @@
const init = (target: Page | NavigationTarget | null) => {
if (memoryManager.memories.length === 0) {
return handlePromiseError(goto(Route.photos()));
return handlePromiseError(goto(previousPage));
}
current = loadFromParams(target);
@@ -281,6 +282,10 @@
};
afterNavigate(({ from, to }) => {
if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) {
previousPage = from!.url.toString();
}
memoryManager.ready().then(
() => {
let target;
@@ -381,7 +386,7 @@
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(Route.photos())}
onclick={() => goto(previousPage)}
/>
<p class="text-lg">
{$memoryLaneTitle(current.memory)}
@@ -33,12 +33,14 @@
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -90,6 +92,10 @@
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) {
memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) };
}
</script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>